From dd23205d0bbb3431f6608f77b5c42a91195b7dcb Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 24 Jul 2026 14:59:44 +0100 Subject: [PATCH] style updates --- Modules/AI/Config/config.php | 1 + Modules/AI/Console/PruneAIExecutions.php | 17 +- Modules/AI/Console/TestAIProvider.php | 16 +- Modules/AI/Contracts/AIManager.php | 6 +- Modules/AI/Contracts/AIProviderDriver.php | 6 +- .../2026_07_24_140000_create_ai_tables.php | 94 +++++++--- Modules/AI/Drivers/OpenAICompatibleDriver.php | 27 ++- .../AI/Http/Controllers/AIApiController.php | 12 +- Modules/AI/Http/Controllers/AIController.php | 53 ++++-- Modules/AI/Models/AIExecution.php | 8 +- Modules/AI/Models/AIKnowledgeChunk.php | 8 +- Modules/AI/Models/AIKnowledgeSource.php | 20 +- Modules/AI/Models/AIPrompt.php | 11 +- Modules/AI/Models/AIProvider.php | 11 +- Modules/AI/Providers/AIServiceProvider.php | 8 +- Modules/AI/Providers/RouteServiceProvider.php | 3 + Modules/AI/Routes/api.php | 4 +- Modules/AI/Routes/web.php | 16 +- Modules/AI/Services/AIManager.php | 53 ++++-- Modules/AI/Services/KnowledgeIndexer.php | 37 +++- Modules/AI/Tests/Feature/ModuleBootTest.php | 9 +- .../Http/Controllers/ModuleController.php | 1 + Modules/Administration/Support/ModuleInfo.php | 11 +- Modules/Analytics/Config/config.php | 1 + .../Analytics/Console/AggregateAnalytics.php | 15 +- Modules/Analytics/Console/PruneAnalytics.php | 16 +- .../Analytics/Console/RunScheduledReports.php | 14 +- ...6_07_24_150000_create_analytics_tables.php | 84 ++++++--- .../Http/Controllers/AnalyticsController.php | 37 ++-- .../Http/Controllers/TrackingController.php | 18 +- Modules/Analytics/Models/AnalyticsEvent.php | 8 +- Modules/Analytics/Models/AnalyticsReport.php | 8 +- .../Analytics/Models/AnalyticsReportRun.php | 8 +- Modules/Analytics/Models/DailyMetric.php | 11 +- .../Providers/AnalyticsServiceProvider.php | 4 + .../Providers/RouteServiceProvider.php | 3 + Modules/Analytics/Routes/api.php | 4 +- Modules/Analytics/Routes/web.php | 10 +- .../Services/AnalyticsAggregator.php | 29 +-- .../Tests/Feature/AnalyticsModuleTest.php | 8 +- Modules/Api/Config/config.php | 3 +- ...6_07_24_090000_create_api_tokens_table.php | 28 ++- ...26_07_24_090100_create_webhooks_tables.php | 44 ++++- .../Api/Http/Controllers/ApiController.php | 49 ++++- .../Http/Controllers/ContentController.php | 54 +++++- .../Http/Controllers/OpenApiController.php | 11 +- .../Http/Middleware/AuthenticateApiToken.php | 28 ++- .../Api/Http/Resources/ContentResource.php | 14 +- Modules/Api/Models/ApiToken.php | 40 +++- Modules/Api/Models/Webhook.php | 28 ++- Modules/Api/Models/WebhookDelivery.php | 21 ++- Modules/Api/Providers/ApiServiceProvider.php | 19 +- .../Api/Providers/RouteServiceProvider.php | 12 +- Modules/Api/Routes/api.php | 19 +- Modules/Api/Routes/web.php | 13 +- Modules/Api/Services/WebhookDispatcher.php | 28 ++- .../Api/Tests/Feature/ApiModuleBootTest.php | 12 +- ...6_07_23_000200_create_audit_logs_table.php | 29 ++- .../Http/Controllers/AuditController.php | 14 +- Modules/Audit/Models/AuditLog.php | 18 +- Modules/Audit/Routes/web.php | 7 +- Modules/Audit/Support/ModuleInfo.php | 11 +- Modules/Authors/Config/config.php | 1 + ...2026_07_24_030300_create_authors_table.php | 32 +++- .../Http/Controllers/AuthorController.php | 57 +++++- Modules/Authors/Models/Author.php | 23 ++- .../Providers/AuthorsServiceProvider.php | 4 + .../Providers/RouteServiceProvider.php | 3 + Modules/Authors/Routes/web.php | 9 +- .../Authors/Tests/Feature/ModuleBootTest.php | 3 + Modules/Automation/Config/config.php | 1 + .../Console/DispatchScheduledAutomations.php | 23 ++- .../Automation/Contracts/ActionRegistry.php | 4 + .../Automation/Contracts/TriggerRegistry.php | 3 + ..._07_24_130000_create_automation_tables.php | 62 +++++-- .../Controllers/AutomationApiController.php | 16 +- .../Http/Controllers/AutomationController.php | 58 ++++-- Modules/Automation/Jobs/ExecuteAutomation.php | 36 +++- Modules/Automation/Models/Automation.php | 26 ++- Modules/Automation/Models/AutomationRun.php | 20 +- .../Automation/Models/AutomationRunLog.php | 11 +- Modules/Automation/Models/AutomationStep.php | 14 +- .../Providers/AutomationServiceProvider.php | 8 +- .../Providers/RouteServiceProvider.php | 3 + Modules/Automation/Routes/api.php | 4 +- Modules/Automation/Routes/web.php | 16 +- .../Services/AutomationActionRegistry.php | 73 +++++--- .../Automation/Services/AutomationRunner.php | 38 +++- .../Services/AutomationTriggerRegistry.php | 17 +- .../Tests/Feature/ModuleBootTest.php | 9 +- .../Http/Controllers/BannerController.php | 49 ++++- Modules/Banners/Models/Banner.php | 16 +- Modules/Banners/Support/ModuleInfo.php | 13 +- .../Banners/Tests/Feature/ModuleBootTest.php | 4 +- Modules/Blog/Config/config.php | 1 + ...26_07_24_03041_create_blog_posts_table.php | 41 ++++- .../Blog/Http/Controllers/BlogController.php | 66 ++++++- .../Http/Controllers/PublicBlogController.php | 21 ++- Modules/Blog/Models/Blog.php | 47 ++++- .../Blog/Providers/BlogServiceProvider.php | 4 + .../Blog/Providers/RouteServiceProvider.php | 3 + Modules/Blog/Routes/web.php | 13 +- .../Builder/Components/ContentComponent.php | 38 +++- .../Builder/Components/HeadingComponent.php | 40 +++- Modules/Builder/Components/HeroComponent.php | 38 +++- Modules/Builder/Config/config.php | 3 +- .../Builder/Contracts/BuilderComponent.php | 7 + ...026_07_24_160000_create_builder_tables.php | 59 ++++-- .../Http/Controllers/BuilderApiController.php | 20 +- .../Http/Controllers/BuilderController.php | 52 +++++- Modules/Builder/Models/BuilderDocument.php | 14 +- .../Builder/Models/BuilderDocumentVersion.php | 8 +- Modules/Builder/Models/BuilderTemplate.php | 8 +- .../Providers/BuilderServiceProvider.php | 4 + .../Providers/RouteServiceProvider.php | 3 + Modules/Builder/Routes/api.php | 8 +- Modules/Builder/Routes/web.php | 16 +- .../Services/BuilderDocumentService.php | 24 ++- Modules/Builder/Services/BuilderRegistry.php | 56 ++++-- Modules/Builder/Services/BuilderRenderer.php | 14 +- .../Tests/Feature/BuilderModuleTest.php | 7 +- Modules/CRM/Config/config.php | 1 + .../2026_07_24_04000_create_crm_tables.php | 86 ++++++++- .../CRM/Http/Controllers/CRMController.php | 60 +++++- Modules/CRM/Models/Activity.php | 24 ++- Modules/CRM/Models/Company.php | 24 ++- Modules/CRM/Models/Contact.php | 29 ++- Modules/CRM/Models/Lead.php | 24 ++- Modules/CRM/Models/Opportunity.php | 29 ++- Modules/CRM/Providers/CRMServiceProvider.php | 4 + .../CRM/Providers/RouteServiceProvider.php | 3 + Modules/CRM/Routes/web.php | 14 +- Modules/CRM/Tests/Feature/ModuleBootTest.php | 3 + Modules/Careers/Config/config.php | 1 + ...2026_07_24_04300_create_careers_tables.php | 44 ++++- .../Http/Controllers/CareerController.php | 33 +++- Modules/Careers/Models/Application.php | 13 +- Modules/Careers/Models/Job.php | 24 ++- .../Providers/CareersServiceProvider.php | 4 + .../Providers/RouteServiceProvider.php | 3 + Modules/Careers/Routes/web.php | 11 +- .../Careers/Tests/Feature/ModuleBootTest.php | 3 + Modules/Categories/Config/config.php | 1 + ...6_07_24_030100_create_categories_table.php | 26 ++- .../Http/Controllers/CategoryController.php | 53 +++++- Modules/Categories/Models/Category.php | 23 ++- .../Providers/CategoriesServiceProvider.php | 4 + .../Providers/RouteServiceProvider.php | 3 + Modules/Categories/Routes/web.php | 6 +- .../Tests/Feature/ModuleBootTest.php | 3 + .../Commands/CloudDevOpsHealthCommand.php | 18 +- .../Commands/CloudDevOpsPruneCommand.php | 19 +- ...7_24_180000_create_cloud_devops_tables.php | 3 +- .../Controllers/CloudDevOpsApiController.php | 16 +- .../Controllers/CloudDevOpsController.php | 7 +- .../Models/DeploymentEnvironment.php | 30 ++- .../Models/DeploymentHealthCheck.php | 13 +- .../CloudDevOps/Models/DeploymentPipeline.php | 13 +- .../CloudDevOps/Models/DeploymentRelease.php | 23 ++- .../CloudDevOps/Models/DeploymentRollback.php | 8 +- .../CloudDevOps/Models/DeploymentSecret.php | 13 +- Modules/CloudDevOps/Models/DeploymentStep.php | 13 +- .../CloudDevOps/Models/DeploymentTarget.php | 13 +- .../Providers/CloudDevOpsServiceProvider.php | 21 ++- .../Providers/RouteServiceProvider.php | 12 +- Modules/CloudDevOps/Routes/api.php | 7 +- .../Services/DeploymentManager.php | 1 + .../Services/HealthCheckRunner.php | 21 ++- .../Tests/Feature/CloudDevOpsModuleTest.php | 14 +- Modules/Comments/Config/config.php | 1 + ...026_07_24_030600_create_comments_table.php | 32 +++- .../Http/Controllers/CommentController.php | 39 +++- Modules/Comments/Models/Comment.php | 22 ++- .../Providers/CommentsServiceProvider.php | 4 + .../Providers/RouteServiceProvider.php | 3 + Modules/Comments/Routes/web.php | 11 +- .../Comments/Tests/Feature/ModuleBootTest.php | 3 + Modules/Commerce/Config/config.php | 1 + .../Commerce/Contracts/InventoryProvider.php | 10 +- .../Commerce/Contracts/PaymentProvider.php | 10 +- .../Commerce/Contracts/ShippingProvider.php | 7 +- Modules/Commerce/Contracts/TaxProvider.php | 7 +- ...26_07_24_110000_create_commerce_tables.php | 174 ++++++++++++++++-- .../Http/Controllers/CommerceController.php | 42 ++++- .../Http/Controllers/ProductApiController.php | 14 +- Modules/Commerce/Models/Category.php | 12 +- Modules/Commerce/Models/Customer.php | 12 +- Modules/Commerce/Models/InventoryMovement.php | 12 +- Modules/Commerce/Models/Order.php | 12 +- Modules/Commerce/Models/OrderItem.php | 12 +- Modules/Commerce/Models/Product.php | 11 +- Modules/Commerce/Models/ProductVariant.php | 12 +- Modules/Commerce/Models/Promotion.php | 12 +- Modules/Commerce/Models/Relations.php | 1 + .../Providers/CommerceServiceProvider.php | 10 +- .../Providers/RouteServiceProvider.php | 3 + Modules/Commerce/Routes/api.php | 6 +- Modules/Commerce/Routes/web.php | 14 +- .../Services/ConfiguredTaxProvider.php | 11 +- .../Services/DatabaseInventoryProvider.php | 14 +- .../Services/FlatRateShippingProvider.php | 11 +- .../Services/ManualPaymentProvider.php | 15 +- .../Commerce/Tests/Feature/ModuleBootTest.php | 15 +- Modules/Core/Contracts/ModuleRegistry.php | 1 + .../Core/Providers/CoreServiceProvider.php | 2 +- Modules/Core/Routes/web.php | 2 - .../Core/Services/LaravelModuleRegistry.php | 6 +- Modules/Core/Support/ModuleInfo.php | 11 +- .../Commands/InspectModulesCommand.php | 18 +- .../Console/Commands/MakeCrudCommand.php | 15 +- .../Console/Commands/MakeModuleCommand.php | 18 +- .../Console/Commands/MakeThemeCommand.php | 15 +- Modules/Developer/Support/CrudGenerator.php | 1 + Modules/Developer/Support/ThemeGenerator.php | 7 +- ...26_07_24_000500_create_documents_table.php | 19 +- .../Http/Controllers/DocumentController.php | 49 ++++- Modules/Documents/Models/Document.php | 16 +- Modules/Documents/Support/ModuleInfo.php | 13 +- .../Tests/Feature/ModuleBootTest.php | 4 +- Modules/Enterprise/Config/config.php | 1 + .../Console/BackupCreateCommand.php | 11 +- .../Enterprise/Console/BackupPruneCommand.php | 10 +- .../Enterprise/Console/HealthCheckCommand.php | 8 +- ...00_create_enterprise_health_runs_table.php | 4 +- ...100100_create_enterprise_backups_table.php | 4 +- .../Http/Controllers/EnterpriseController.php | 8 + Modules/Enterprise/Models/Backup.php | 14 +- Modules/Enterprise/Models/HealthRun.php | 14 +- .../Providers/EnterpriseServiceProvider.php | 1 + .../Providers/RouteServiceProvider.php | 1 + Modules/Enterprise/Routes/web.php | 1 + Modules/Enterprise/Services/BackupService.php | 20 +- .../Services/SystemHealthService.php | 14 +- .../Feature/EnterpriseModuleBootTest.php | 4 +- Modules/Events/Config/config.php | 1 + .../2026_07_24_04200_create_events_tables.php | 52 +++++- .../Http/Controllers/EventController.php | 33 +++- Modules/Events/Models/Event.php | 28 ++- Modules/Events/Models/Registration.php | 13 +- Modules/Events/Models/Venue.php | 13 +- .../Providers/EventsServiceProvider.php | 4 + .../Events/Providers/RouteServiceProvider.php | 3 + Modules/Events/Routes/web.php | 11 +- .../Events/Tests/Feature/ModuleBootTest.php | 3 + .../2026_07_24_000700_create_faqs_table.php | 19 +- .../Faq/Http/Controllers/FaqController.php | 49 ++++- Modules/Faq/Models/Faq.php | 18 +- Modules/Faq/Support/ModuleInfo.php | 13 +- Modules/Faq/Tests/Feature/ModuleBootTest.php | 4 +- Modules/Feeds/Config/config.php | 1 + .../Feeds/Http/Controllers/FeedController.php | 22 ++- .../Feeds/Providers/FeedsServiceProvider.php | 4 + .../Feeds/Providers/RouteServiceProvider.php | 3 + Modules/Feeds/Routes/web.php | 8 +- .../Feeds/Tests/Feature/ModuleBootTest.php | 3 + .../2026_07_24_000400_create_forms_table.php | 19 +- .../Forms/Http/Controllers/FormController.php | 57 +++++- Modules/Forms/Models/Form.php | 18 +- Modules/Forms/Support/ModuleInfo.php | 13 +- .../Forms/Tests/Feature/ModuleBootTest.php | 4 +- .../Localization/Concerns/HasTranslations.php | 11 +- Modules/Localization/Config/config.php | 11 +- ...07_24_06000_create_localization_tables.php | 3 + .../Database/Seeders/LocaleSeeder.php | 3 + .../Http/Controllers/LocaleController.php | 36 +++- .../Controllers/TranslationController.php | 8 + Modules/Localization/Models/Locale.php | 23 ++- .../Localization/Models/LocalizedContent.php | 23 ++- .../Providers/LocalizationServiceProvider.php | 4 + .../Providers/RouteServiceProvider.php | 3 + Modules/Localization/Routes/web.php | 4 +- .../Localization/Services/LocaleManager.php | 16 +- .../Tests/Feature/ModuleBootTest.php | 6 +- Modules/Locations/Config/config.php | 1 + ...026_07_24_04400_create_locations_table.php | 35 +++- .../Http/Controllers/LocationController.php | 32 +++- Modules/Locations/Models/Location.php | 16 +- .../Providers/LocationsServiceProvider.php | 4 + .../Providers/RouteServiceProvider.php | 3 + Modules/Locations/Routes/web.php | 11 +- .../Tests/Feature/ModuleBootTest.php | 3 + Modules/Marketplace/Config/config.php | 1 + .../Commands/MarketplaceAuditCommand.php | 18 +- .../Commands/MarketplaceSyncCommand.php | 18 +- ...07_24_170000_create_marketplace_tables.php | 102 +++++++--- .../Controllers/MarketplaceApiController.php | 34 +++- .../Controllers/MarketplaceController.php | 56 +++++- Modules/Marketplace/Models/Installation.php | 11 +- .../Marketplace/Models/InstalledPackage.php | 29 ++- .../Marketplace/Models/MarketplaceLicence.php | 14 +- .../Marketplace/Models/MarketplacePackage.php | 23 ++- Modules/Marketplace/Models/Publisher.php | 17 +- .../Providers/MarketplaceServiceProvider.php | 8 +- .../Providers/RouteServiceProvider.php | 3 + Modules/Marketplace/Routes/api.php | 12 +- Modules/Marketplace/Routes/web.php | 18 +- .../Services/CompatibilityChecker.php | 29 ++- .../Services/DependencyResolver.php | 26 ++- .../Services/MarketplaceManager.php | 75 +++++--- .../Marketplace/Services/PackageManifest.php | 34 +++- .../Marketplace/Services/PackageVerifier.php | 28 ++- .../Services/VersionConstraint.php | 51 +++-- .../Tests/Feature/MarketplaceModuleTest.php | 11 +- .../2026_07_23_000300_create_media_table.php | 38 +++- .../Http/Controllers/MediaController.php | 36 +++- Modules/Media/Models/Media.php | 27 ++- Modules/Media/Routes/web.php | 11 +- Modules/Media/Support/ModuleInfo.php | 11 +- .../2026_07_24_000200_create_menus_table.php | 19 +- .../Menus/Http/Controllers/MenuController.php | 57 +++++- Modules/Menus/Models/Menu.php | 18 +- Modules/Menus/Support/ModuleInfo.php | 13 +- .../Menus/Tests/Feature/ModuleBootTest.php | 4 +- Modules/News/Config/config.php | 1 + ...07_24_03042_create_news_articles_table.php | 41 ++++- .../News/Http/Controllers/NewsController.php | 66 ++++++- .../Http/Controllers/PublicNewsController.php | 21 ++- Modules/News/Models/News.php | 47 ++++- .../News/Providers/NewsServiceProvider.php | 4 + .../News/Providers/RouteServiceProvider.php | 3 + Modules/News/Routes/web.php | 13 +- Modules/News/Tests/Feature/ModuleBootTest.php | 3 + Modules/Newsletter/Config/config.php | 1 + ...6_07_24_04100_create_newsletter_tables.php | 58 +++++- .../Http/Controllers/NewsletterController.php | 42 ++++- Modules/Newsletter/Models/Campaign.php | 18 +- Modules/Newsletter/Models/MailingList.php | 18 +- Modules/Newsletter/Models/Subscriber.php | 18 +- .../Providers/NewsletterServiceProvider.php | 4 + .../Providers/RouteServiceProvider.php | 3 + Modules/Newsletter/Routes/web.php | 12 +- .../Tests/Feature/ModuleBootTest.php | 3 + ...24_030700_enhance_pages_for_publishing.php | 37 +++- .../Pages/Http/Controllers/PageController.php | 49 ++++- .../Http/Controllers/PublicPageController.php | 13 +- Modules/Pages/Models/Page.php | 20 +- Modules/Pages/Routes/web.php | 3 +- .../Pages/Tests/Feature/ModuleBootTest.php | 4 +- ...026_07_24_105054_create_products_table.php | 19 +- .../Http/Controllers/ProductController.php | 1 + .../Http/Requests/StoreProductRequest.php | 11 +- .../Tests/Feature/ProductCrudTest.php | 4 +- .../Tests/Feature/ProductsModuleBootTest.php | 4 +- ...26_07_24_000300_create_redirects_table.php | 19 +- .../Http/Controllers/RedirectController.php | 49 ++++- Modules/Redirects/Models/Redirect.php | 18 +- Modules/Redirects/Support/ModuleInfo.php | 13 +- .../Tests/Feature/ModuleBootTest.php | 4 +- .../Roles/Http/Controllers/RoleController.php | 52 +++++- Modules/Roles/Routes/web.php | 9 +- Modules/Roles/Support/ModuleInfo.php | 11 +- ...07_23_000500_create_search_index_table.php | 31 +++- .../Http/Controllers/SearchController.php | 18 +- Modules/Search/Routes/web.php | 7 +- Modules/Search/Support/ModuleInfo.php | 11 +- Modules/Security/Config/config.php | 1 + ...26_07_24_120000_create_security_tables.php | 81 +++++--- .../Http/Controllers/SecurityController.php | 39 +++- .../Http/Middleware/EnforceIpRestrictions.php | 9 +- .../Security/Listeners/RecordFailedLogin.php | 12 +- Modules/Security/Listeners/RecordLogin.php | 12 +- Modules/Security/Listeners/RecordLogout.php | 12 +- Modules/Security/Models/IpRule.php | 11 +- Modules/Security/Models/LoginEvent.php | 11 +- Modules/Security/Models/SecurityPolicy.php | 8 +- Modules/Security/Models/TrustedDevice.php | 11 +- .../Providers/RouteServiceProvider.php | 3 + .../Providers/SecurityServiceProvider.php | 4 + Modules/Security/Routes/web.php | 14 +- Modules/Security/Services/IpRuleService.php | 43 +++-- .../Security/Tests/Feature/ModuleBootTest.php | 14 +- ...07_23_000400_create_seo_metadata_table.php | 37 +++- .../Seo/Http/Controllers/SeoController.php | 28 ++- Modules/Seo/Models/SeoMetadata.php | 18 +- Modules/Seo/Routes/web.php | 10 +- Modules/Seo/Support/ModuleInfo.php | 11 +- ...026_07_23_000100_create_settings_table.php | 23 ++- .../Http/Controllers/SettingController.php | 28 ++- Modules/Settings/Models/Setting.php | 8 +- Modules/Settings/Routes/web.php | 10 +- .../Settings/Services/SettingsRepository.php | 11 +- Modules/Settings/Support/ModuleInfo.php | 11 +- Modules/Sitemap/Config/config.php | 1 + .../Http/Controllers/SitemapController.php | 24 ++- .../Providers/RouteServiceProvider.php | 3 + .../Providers/SitemapServiceProvider.php | 4 + Modules/Sitemap/Routes/web.php | 6 +- .../Sitemap/Tests/Feature/ModuleBootTest.php | 3 + Modules/Sites/Concerns/BelongsToSites.php | 10 +- Modules/Sites/Config/config.php | 11 +- Modules/Sites/Contracts/SiteAwareModule.php | 3 + ...6_07_24_070000_create_multisite_tables.php | 56 +++++- Modules/Sites/Database/Seeders/SiteSeeder.php | 7 +- .../Http/Controllers/DomainController.php | 24 ++- .../Sites/Http/Controllers/SiteController.php | 61 +++++- Modules/Sites/Http/Middleware/ResolveSite.php | 5 + Modules/Sites/Models/Site.php | 37 +++- Modules/Sites/Models/SiteContent.php | 24 ++- Modules/Sites/Models/SiteDomain.php | 17 +- Modules/Sites/Models/SiteSetting.php | 12 +- .../Sites/Providers/RouteServiceProvider.php | 9 +- .../Sites/Providers/SitesServiceProvider.php | 4 + Modules/Sites/Routes/web.php | 10 +- .../Tests/Feature/SitesModuleBootTest.php | 3 + .../Stylist/Console/CreateThemeCommand.php | 4 + .../Console/FindDuplicateViewsCommand.php | 3 + .../Stylist/Console/InspectThemesCommand.php | 1 + .../Stylist/Console/PublishAssetsCommand.php | 1 + .../Stylist/Console/ThemeDoctorCommand.php | 2 + .../Stylist/Console/ValidateThemeCommand.php | 4 +- ...01_create_stylist_theme_settings_table.php | 20 +- .../Stylist/Diagnostics/ThemeInspector.php | 1 + Modules/Stylist/Helpers/helpers.php | 3 +- Modules/Stylist/Html/ThemeHtmlBuilder.php | 18 +- Modules/Stylist/Html/ThemeSeoBuilder.php | 39 +++- .../Controllers/ThemeManagementController.php | 21 ++- Modules/Stylist/Models/ThemeSetting.php | 2 + .../Providers/StylistServiceProvider.php | 2 +- .../Services/ThemeAccessibilityValidator.php | 1 + Modules/Stylist/Services/ThemeOptimizer.php | 24 ++- .../Stylist/Services/ThemePackageManager.php | 67 +++++-- Modules/Stylist/Services/ThemeSettings.php | 31 +++- .../Tests/Feature/ThemeManifestTest.php | 2 +- .../Feature/ThemePackageRoundTripTest.php | 3 +- Modules/Stylist/Theme/ManifestValidator.php | 12 +- Modules/Stylist/Theme/Stylist.php | 49 +++-- Modules/Stylist/Theme/Theme.php | 164 ++++++++++++++--- Modules/Stylist/Theme/UrlGenerator.php | 8 +- Modules/Tags/Config/config.php | 1 + .../2026_07_24_030200_create_tags_table.php | 26 ++- .../Tags/Http/Controllers/TagController.php | 48 ++++- Modules/Tags/Models/Tag.php | 16 +- .../Tags/Providers/RouteServiceProvider.php | 3 + .../Tags/Providers/TagsServiceProvider.php | 4 + Modules/Tags/Routes/web.php | 9 +- Modules/Tags/Tests/Feature/ModuleBootTest.php | 3 + ...07_24_000800_create_team_members_table.php | 19 +- .../Http/Controllers/TeamMemberController.php | 49 ++++- Modules/Team/Models/TeamMember.php | 18 +- Modules/Team/Support/ModuleInfo.php | 13 +- Modules/Team/Tests/Feature/ModuleBootTest.php | 4 +- ...07_24_000900_create_testimonials_table.php | 21 ++- .../Controllers/TestimonialController.php | 49 ++++- Modules/Testimonials/Models/Testimonial.php | 18 +- Modules/Testimonials/Support/ModuleInfo.php | 13 +- .../Tests/Feature/ModuleBootTest.php | 4 +- .../Users/Http/Controllers/UserController.php | 55 ++++-- Modules/Users/Routes/web.php | 9 +- Modules/Users/Support/ModuleInfo.php | 11 +- Modules/Workflow/Concerns/HasWorkflow.php | 19 +- Modules/Workflow/Config/config.php | 1 + Modules/Workflow/Contracts/Workflowable.php | 10 +- ...026_07_24_05000_create_workflow_tables.php | 85 ++++++++- .../Http/Controllers/WorkflowController.php | 46 ++++- .../Workflow/Models/WorkflowDefinition.php | 26 ++- Modules/Workflow/Models/WorkflowHistory.php | 15 +- Modules/Workflow/Models/WorkflowInstance.php | 38 +++- Modules/Workflow/Models/WorkflowStage.php | 21 ++- .../Workflow/Models/WorkflowTransition.php | 13 +- .../Providers/RouteServiceProvider.php | 12 +- .../Providers/WorkflowServiceProvider.php | 20 +- Modules/Workflow/Routes/web.php | 11 +- Modules/Workflow/Services/WorkflowManager.php | 47 ++++- .../Workflow/Tests/Feature/ModuleBootTest.php | 12 +- app/Livewire/Settings/Appearance.php | 1 - app/Livewire/Settings/DeleteUserForm.php | 3 +- app/Livewire/Settings/Profile.php | 3 +- app/Livewire/Settings/Security.php | 3 +- .../Settings/TwoFactor/RecoveryCodes.php | 3 +- app/Providers/AppServiceProvider.php | 1 - bootstrap/app.php | 6 +- config/modules.php | 5 +- tests/Feature/Auth/AuthenticationTest.php | 2 +- tests/Feature/Auth/EmailVerificationTest.php | 2 +- .../Feature/Auth/PasswordConfirmationTest.php | 2 +- tests/Feature/Auth/PasswordResetTest.php | 2 +- tests/Feature/Auth/RegistrationTest.php | 2 +- tests/Feature/Auth/TwoFactorChallengeTest.php | 2 +- tests/Feature/DashboardTest.php | 2 +- tests/Feature/ExampleTest.php | 2 +- tests/Feature/Settings/ProfileUpdateTest.php | 2 +- tests/Feature/Settings/SecurityTest.php | 2 +- 482 files changed, 7173 insertions(+), 1435 deletions(-) diff --git a/Modules/AI/Config/config.php b/Modules/AI/Config/config.php index cbd8a62..573f94d 100644 --- a/Modules/AI/Config/config.php +++ b/Modules/AI/Config/config.php @@ -1,4 +1,5 @@ env('AI_DEFAULT_PROVIDER'), 'timeout' => (int) env('AI_TIMEOUT', 60), diff --git a/Modules/AI/Console/PruneAIExecutions.php b/Modules/AI/Console/PruneAIExecutions.php index 80b1cf9..5993517 100644 --- a/Modules/AI/Console/PruneAIExecutions.php +++ b/Modules/AI/Console/PruneAIExecutions.php @@ -1,9 +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; } + protected $signature = 'ai:prune {--days=}'; + + protected $description = 'Remove old AI execution records.'; + + public function handle(): int + { + $days = (int) ($this->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/Modules/AI/Console/TestAIProvider.php b/Modules/AI/Console/TestAIProvider.php index 97c8745..7b192bd 100644 --- a/Modules/AI/Console/TestAIProvider.php +++ b/Modules/AI/Console/TestAIProvider.php @@ -1,9 +1,21 @@ testProvider((int)$this->argument('provider')); $this->{$result['ok']?'info':'error'}($result['message']); return $result['ok']?self::SUCCESS:self::FAILURE; } + protected $signature = 'ai:test {provider}'; + + protected $description = 'Test an AI provider connection.'; + + public function handle(AIManager $manager): int + { + $result = $manager->testProvider((int) $this->argument('provider')); + $this->{$result['ok'] ? 'info' : 'error'}($result['message']); + + return $result['ok'] ? self::SUCCESS : self::FAILURE; + } } diff --git a/Modules/AI/Contracts/AIManager.php b/Modules/AI/Contracts/AIManager.php index 55dda71..d7cfe3a 100644 --- a/Modules/AI/Contracts/AIManager.php +++ b/Modules/AI/Contracts/AIManager.php @@ -1,9 +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(); + $table->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(); + $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(); + $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(); + $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']); + $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'); + 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/Modules/AI/Drivers/OpenAICompatibleDriver.php b/Modules/AI/Drivers/OpenAICompatibleDriver.php index 2e24a21..74a7d5a 100644 --- a/Modules/AI/Drivers/OpenAICompatibleDriver.php +++ b/Modules/AI/Drivers/OpenAICompatibleDriver.php @@ -1,27 +1,36 @@ timeout((int)config('ai.timeout',60)); + $client = Http::acceptJson()->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 + + 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]; + $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.']; + $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/Modules/AI/Http/Controllers/AIApiController.php b/Modules/AI/Http/Controllers/AIApiController.php index 779facc..77000a1 100644 --- a/Modules/AI/Http/Controllers/AIApiController.php +++ b/Modules/AI/Http/Controllers/AIApiController.php @@ -1,16 +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); + $data = $request->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/Modules/AI/Http/Controllers/AIController.php b/Modules/AI/Http/Controllers/AIController.php index e9dd69c..96c1f5f 100644 --- a/Modules/AI/Http/Controllers/AIController.php +++ b/Modules/AI/Http/Controllers/AIController.php @@ -1,5 +1,7 @@ AIProvider::latest()->get(),'prompts'=>AIPrompt::latest()->paginate(15,'*','prompts'),'executions'=>AIExecution::latest()->limit(15)->get(),'sources'=>AIKnowledgeSource::latest()->limit(15)->get()]); + return view('ai::admin.index', ['providers' => 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.'); + $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 + + public function testProvider(AIProvider $provider, AIManager $manager): RedirectResponse { - $result=$manager->testProvider($provider->id); return back()->with($result['ok']?'status':'error',$result['message']); + $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.'); + $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 + + 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); + $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 + + 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.'); + $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/Modules/AI/Models/AIExecution.php b/Modules/AI/Models/AIExecution.php index 65a571f..62ea778 100644 --- a/Modules/AI/Models/AIExecution.php +++ b/Modules/AI/Models/AIExecution.php @@ -1,8 +1,12 @@ 'array','usage'=>'array','completed_at'=>'datetime']; + protected $fillable = ['execution_id', 'ai_provider_id', 'ai_prompt_id', 'user_id', 'model', 'purpose', 'input', 'output', 'context', 'usage', 'status', 'error', 'duration_ms', 'completed_at']; + + protected $casts = ['context' => 'array', 'usage' => 'array', 'completed_at' => 'datetime']; } diff --git a/Modules/AI/Models/AIKnowledgeChunk.php b/Modules/AI/Models/AIKnowledgeChunk.php index 93ac068..632ed3c 100644 --- a/Modules/AI/Models/AIKnowledgeChunk.php +++ b/Modules/AI/Models/AIKnowledgeChunk.php @@ -1,8 +1,12 @@ 'array']; + protected $fillable = ['ai_knowledge_source_id', 'position', 'content', 'metadata']; + + protected $casts = ['metadata' => 'array']; } diff --git a/Modules/AI/Models/AIKnowledgeSource.php b/Modules/AI/Models/AIKnowledgeSource.php index 18fb99e..f863a52 100644 --- a/Modules/AI/Models/AIKnowledgeSource.php +++ b/Modules/AI/Models/AIKnowledgeSource.php @@ -1,12 +1,24 @@ 'array','indexed_at'=>'datetime']; - public function source(): MorphTo { return $this->morphTo(); } - public function chunks(): HasMany { return $this->hasMany(AIKnowledgeChunk::class); } + protected $fillable = ['name', 'source_kind', 'source_type', 'source_id', 'content', 'checksum', 'metadata', 'status', 'chunk_count', 'indexed_at']; + + protected $casts = ['metadata' => 'array', 'indexed_at' => 'datetime']; + + public function source(): MorphTo + { + return $this->morphTo(); + } + + public function chunks(): HasMany + { + return $this->hasMany(AIKnowledgeChunk::class); + } } diff --git a/Modules/AI/Models/AIPrompt.php b/Modules/AI/Models/AIPrompt.php index 3dbed94..5e1a23f 100644 --- a/Modules/AI/Models/AIPrompt.php +++ b/Modules/AI/Models/AIPrompt.php @@ -1,12 +1,17 @@ 'array','is_active'=>'boolean']; + protected $fillable = ['name', 'slug', 'purpose', 'system_prompt', 'user_template', 'variables', 'version', 'is_active', 'created_by']; + + protected $casts = ['variables' => '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; + return preg_replace_callback('/\{\{\s*([\w.]+)\s*\}\}/', fn ($m) => (string) data_get($variables, $m[1], ''), $this->user_template) ?? $this->user_template; } } diff --git a/Modules/AI/Models/AIProvider.php b/Modules/AI/Models/AIProvider.php index 7450926..f96e235 100644 --- a/Modules/AI/Models/AIProvider.php +++ b/Modules/AI/Models/AIProvider.php @@ -1,9 +1,14 @@ 'encrypted','models'=>'array','options'=>'array','is_active'=>'boolean','is_default'=>'boolean','last_tested_at'=>'datetime']; - protected $hidden=['api_key']; + protected $fillable = ['name', 'driver', 'base_url', 'api_key', 'default_model', 'models', 'options', 'is_active', 'is_default', 'last_tested_at', 'last_test_status', 'last_test_message']; + + protected $casts = ['api_key' => 'encrypted', 'models' => 'array', 'options' => 'array', 'is_active' => 'boolean', 'is_default' => 'boolean', 'last_tested_at' => 'datetime']; + + protected $hidden = ['api_key']; } diff --git a/Modules/AI/Providers/AIServiceProvider.php b/Modules/AI/Providers/AIServiceProvider.php index 929b215..e7fec1b 100644 --- a/Modules/AI/Providers/AIServiceProvider.php +++ b/Modules/AI/Providers/AIServiceProvider.php @@ -1,10 +1,13 @@ 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]); + if ($this->app->runningInConsole()) { + $this->commands([TestAIProvider::class, PruneAIExecutions::class]); + } } } diff --git a/Modules/AI/Providers/RouteServiceProvider.php b/Modules/AI/Providers/RouteServiceProvider.php index 7978a18..4306ef6 100644 --- a/Modules/AI/Providers/RouteServiceProvider.php +++ b/Modules/AI/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ middleware(['auth.api','ability:ai.execute'])->name('api.ai.execute'); + +Route::post('/ai/prompts/{prompt:slug}/execute', [AIApiController::class, 'execute'])->middleware(['auth.api', 'ability:ai.execute'])->name('api.ai.execute'); diff --git a/Modules/AI/Routes/web.php b/Modules/AI/Routes/web.php index 2d28220..c2acf21 100644 --- a/Modules/AI/Routes/web.php +++ b/Modules/AI/Routes/web.php @@ -1,11 +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'); + +Route::middleware(['auth', 'permission:ai.view'])->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/Modules/AI/Services/AIManager.php b/Modules/AI/Services/AIManager.php index d74fd7f..2f1c2c0 100644 --- a/Modules/AI/Services/AIManager.php +++ b/Modules/AI/Services/AIManager.php @@ -1,5 +1,7 @@ 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); + $promptModel = is_string($prompt) ? AIPrompt::where('slug', $prompt)->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()]); + $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()]); + 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()]; } + $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(); + 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}].") }; + 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/Modules/AI/Services/KnowledgeIndexer.php b/Modules/AI/Services/KnowledgeIndexer.php index c9eb0ff..9c21d83 100644 --- a/Modules/AI/Services/KnowledgeIndexer.php +++ b/Modules/AI/Services/KnowledgeIndexer.php @@ -1,20 +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()]); + $content = trim((string) $source->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 + + 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; + $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/Modules/AI/Tests/Feature/ModuleBootTest.php b/Modules/AI/Tests/Feature/ModuleBootTest.php index 39704ff..ebc1cac 100644 --- a/Modules/AI/Tests/Feature/ModuleBootTest.php +++ b/Modules/AI/Tests/Feature/ModuleBootTest.php @@ -1,11 +1,16 @@ assertTrue(class_exists(\Modules\AI\Providers\AIServiceProvider::class)); - $this->assertTrue(app()->bound(\Modules\AI\Contracts\AIManager::class)); + $this->assertTrue(class_exists(AIServiceProvider::class)); + $this->assertTrue(app()->bound(AIManager::class)); } } diff --git a/Modules/Administration/Http/Controllers/ModuleController.php b/Modules/Administration/Http/Controllers/ModuleController.php index 3ad262e..e277d1c 100644 --- a/Modules/Administration/Http/Controllers/ModuleController.php +++ b/Modules/Administration/Http/Controllers/ModuleController.php @@ -12,6 +12,7 @@ final class ModuleController extends Controller $modules = collect(glob(base_path('Modules/*/module.json')) ?: [])->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.', diff --git a/Modules/Administration/Support/ModuleInfo.php b/Modules/Administration/Support/ModuleInfo.php index 975a283..51ef6a8 100644 --- a/Modules/Administration/Support/ModuleInfo.php +++ b/Modules/Administration/Support/ModuleInfo.php @@ -4,6 +4,13 @@ namespace Modules\Administration\Support; final class ModuleInfo { - public static function name(): string { return 'Administration'; } - public static function nameLower(): string { return 'administration'; } + public static function name(): string + { + return 'Administration'; + } + + public static function nameLower(): string + { + return 'administration'; + } } diff --git a/Modules/Analytics/Config/config.php b/Modules/Analytics/Config/config.php index 4984472..b2d0b76 100644 --- a/Modules/Analytics/Config/config.php +++ b/Modules/Analytics/Config/config.php @@ -1,4 +1,5 @@ (bool) env('ANALYTICS_ENABLED', true), 'respect_dnt' => (bool) env('ANALYTICS_RESPECT_DNT', true), diff --git a/Modules/Analytics/Console/AggregateAnalytics.php b/Modules/Analytics/Console/AggregateAnalytics.php index 99c45af..ae896e5 100644 --- a/Modules/Analytics/Console/AggregateAnalytics.php +++ b/Modules/Analytics/Console/AggregateAnalytics.php @@ -1,9 +1,20 @@ info('Metrics written: '.$aggregator->aggregate($this->option('date'))); return self::SUCCESS; } + protected $signature = 'analytics:aggregate {--date=}'; + + protected $description = 'Aggregate raw analytics events into daily metrics.'; + + public function handle(AnalyticsAggregator $aggregator): int + { + $this->info('Metrics written: '.$aggregator->aggregate($this->option('date'))); + + return self::SUCCESS; + } } diff --git a/Modules/Analytics/Console/PruneAnalytics.php b/Modules/Analytics/Console/PruneAnalytics.php index c5bb5ac..ead8b95 100644 --- a/Modules/Analytics/Console/PruneAnalytics.php +++ b/Modules/Analytics/Console/PruneAnalytics.php @@ -1,9 +1,21 @@ subDays(config('analytics.retention_days',365)))->delete(); $this->info("Deleted {$count} events."); return self::SUCCESS; } + protected $signature = 'analytics:prune'; + + protected $description = 'Remove analytics events older than the configured retention period.'; + + public function handle(): int + { + $count = AnalyticsEvent::where('occurred_at', '<', now()->subDays(config('analytics.retention_days', 365)))->delete(); + $this->info("Deleted {$count} events."); + + return self::SUCCESS; + } } diff --git a/Modules/Analytics/Console/RunScheduledReports.php b/Modules/Analytics/Console/RunScheduledReports.php index c04bd1f..e169620 100644 --- a/Modules/Analytics/Console/RunScheduledReports.php +++ b/Modules/Analytics/Console/RunScheduledReports.php @@ -1,13 +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; + $count = AnalyticsReport::where('is_active', true)->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/Modules/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php b/Modules/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php index 5326366..4cea18d 100644 --- a/Modules/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php +++ b/Modules/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php @@ -1,42 +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']); + $table->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'); + $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(); + $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(); + $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'); + Schema::dropIfExists('analytics_report_runs'); + Schema::dropIfExists('analytics_reports'); + Schema::dropIfExists('analytics_daily_metrics'); + Schema::dropIfExists('analytics_events'); } }; diff --git a/Modules/Analytics/Http/Controllers/AnalyticsController.php b/Modules/Analytics/Http/Controllers/AnalyticsController.php index d2ffee9..4552d84 100644 --- a/Modules/Analytics/Http/Controllers/AnalyticsController.php +++ b/Modules/Analytics/Http/Controllers/AnalyticsController.php @@ -1,30 +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(), + $days = max(1, min(365, (int) $request->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.'); + 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.'); } - public function destroyReport(AnalyticsReport $report): RedirectResponse { $report->delete(); return back()->with('status','Report deleted.'); } } diff --git a/Modules/Analytics/Http/Controllers/TrackingController.php b/Modules/Analytics/Http/Controllers/TrackingController.php index 31598f4..0523987 100644 --- a/Modules/Analytics/Http/Controllers/TrackingController.php +++ b/Modules/Analytics/Http/Controllers/TrackingController.php @@ -1,21 +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'], + $data = $request->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); + $event = $tracker->track($request, $data); + + return response()->json(['recorded' => (bool) $event], $event ? 201 : 202); } } diff --git a/Modules/Analytics/Models/AnalyticsEvent.php b/Modules/Analytics/Models/AnalyticsEvent.php index 578f089..389e863 100644 --- a/Modules/Analytics/Models/AnalyticsEvent.php +++ b/Modules/Analytics/Models/AnalyticsEvent.php @@ -1,8 +1,12 @@ 'array','occurred_at'=>'datetime']; + protected $fillable = ['event_id', 'site_id', 'event_type', 'path', 'title', 'referrer', 'visitor_hash', 'session_hash', 'ip_hash', 'country_code', 'device_type', 'browser', 'operating_system', 'subject_type', 'subject_id', 'properties', 'occurred_at']; + + protected $casts = ['properties' => 'array', 'occurred_at' => 'datetime']; } diff --git a/Modules/Analytics/Models/AnalyticsReport.php b/Modules/Analytics/Models/AnalyticsReport.php index edbd14e..3d8a81c 100644 --- a/Modules/Analytics/Models/AnalyticsReport.php +++ b/Modules/Analytics/Models/AnalyticsReport.php @@ -1,8 +1,12 @@ 'array','columns'=>'array','recipients'=>'array','is_active'=>'boolean','last_run_at'=>'datetime','next_run_at'=>'datetime']; + protected $fillable = ['site_id', 'name', 'report_type', 'filters', 'columns', 'schedule', 'format', 'recipients', 'is_active', 'last_run_at', 'next_run_at', 'created_by']; + + protected $casts = ['filters' => 'array', 'columns' => 'array', 'recipients' => 'array', 'is_active' => 'boolean', 'last_run_at' => 'datetime', 'next_run_at' => 'datetime']; } diff --git a/Modules/Analytics/Models/AnalyticsReportRun.php b/Modules/Analytics/Models/AnalyticsReportRun.php index 98829d6..53930be 100644 --- a/Modules/Analytics/Models/AnalyticsReportRun.php +++ b/Modules/Analytics/Models/AnalyticsReportRun.php @@ -1,8 +1,12 @@ 'datetime','completed_at'=>'datetime']; + protected $fillable = ['analytics_report_id', 'status', 'disk', 'path', 'row_count', 'error', 'started_at', 'completed_at']; + + protected $casts = ['started_at' => 'datetime', 'completed_at' => 'datetime']; } diff --git a/Modules/Analytics/Models/DailyMetric.php b/Modules/Analytics/Models/DailyMetric.php index 6f3ff47..745d269 100644 --- a/Modules/Analytics/Models/DailyMetric.php +++ b/Modules/Analytics/Models/DailyMetric.php @@ -1,9 +1,14 @@ 'date','metadata'=>'array']; + protected $table = 'analytics_daily_metrics'; + + protected $fillable = ['site_id', 'metric_date', 'metric', 'dimension', 'value', 'metadata']; + + protected $casts = ['metric_date' => 'date', 'metadata' => 'array']; } diff --git a/Modules/Analytics/Providers/AnalyticsServiceProvider.php b/Modules/Analytics/Providers/AnalyticsServiceProvider.php index 1bacbfe..ce1b3d5 100644 --- a/Modules/Analytics/Providers/AnalyticsServiceProvider.php +++ b/Modules/Analytics/Providers/AnalyticsServiceProvider.php @@ -1,10 +1,13 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('Analytics', 'Config/config.php'), 'analytics'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Analytics', 'Database/Migrations')); diff --git a/Modules/Analytics/Providers/RouteServiceProvider.php b/Modules/Analytics/Providers/RouteServiceProvider.php index c1a8eb3..9d62eec 100644 --- a/Modules/Analytics/Providers/RouteServiceProvider.php +++ b/Modules/Analytics/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ middleware('throttle:60,1')->name('api.analytics.events.store'); + +Route::post('/analytics/events', [TrackingController::class, 'store'])->middleware('throttle:60,1')->name('api.analytics.events.store'); diff --git a/Modules/Analytics/Routes/web.php b/Modules/Analytics/Routes/web.php index 287b0a3..5a5437e 100644 --- a/Modules/Analytics/Routes/web.php +++ b/Modules/Analytics/Routes/web.php @@ -1,8 +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'); + +Route::middleware(['auth', 'permission:analytics.view'])->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/Modules/Analytics/Services/AnalyticsAggregator.php b/Modules/Analytics/Services/AnalyticsAggregator.php index e6bb035..0f08c0d 100644 --- a/Modules/Analytics/Services/AnalyticsAggregator.php +++ b/Modules/Analytics/Services/AnalyticsAggregator.php @@ -1,27 +1,32 @@ subHour()->toDateString(); $written=0; - $base=AnalyticsEvent::query()->whereDate('occurred_at',$date); + $date = $date ?: now()->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()], + $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++; + DailyMetric::updateOrCreate(['site_id' => $siteId, 'metric_date' => $date, 'metric' => $metric, 'dimension' => $dimension], ['value' => $value]); + $written++; } } + return $written; } } diff --git a/Modules/Analytics/Tests/Feature/AnalyticsModuleTest.php b/Modules/Analytics/Tests/Feature/AnalyticsModuleTest.php index c1217a4..ce99fab 100644 --- a/Modules/Analytics/Tests/Feature/AnalyticsModuleTest.php +++ b/Modules/Analytics/Tests/Feature/AnalyticsModuleTest.php @@ -1,13 +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']); + $this->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/Modules/Api/Config/config.php b/Modules/Api/Config/config.php index 2df068f..b11bfae 100644 --- a/Modules/Api/Config/config.php +++ b/Modules/Api/Config/config.php @@ -1,2 +1,3 @@ 'api/v1','token_prefix'=>'cms_','pagination'=>20,'webhook_timeout'=>10,'openapi'=>['title'=>env('APP_NAME','Content Manager').' API','version'=>'1.0.0']]; + +return ['prefix' => '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/Modules/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php b/Modules/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php index d0ee037..bb0d7b7 100644 --- a/Modules/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php +++ b/Modules/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php @@ -1,3 +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');} }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('api_tokens', function (Blueprint $t): void { + $t->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/Modules/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php b/Modules/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php index ede41c8..113840c 100644 --- a/Modules/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php +++ b/Modules/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php @@ -1,3 +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');} }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('webhooks', function (Blueprint $t): void { + $t->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/Modules/Api/Http/Controllers/ApiController.php b/Modules/Api/Http/Controllers/ApiController.php index c9376ad..fbe22bf 100644 --- a/Modules/Api/Http/Controllers/ApiController.php +++ b/Modules/Api/Http/Controllers/ApiController.php @@ -1,4 +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.');}} + +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Str; +use Illuminate\View\View; +use Modules\Api\Models\ApiToken; +use Modules\Api\Models\Webhook; + +final class ApiController +{ + public function index(): View + { + return view('api::admin.index', ['tokens' => 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/Modules/Api/Http/Controllers/ContentController.php b/Modules/Api/Http/Controllers/ContentController.php index c3b1248..5be30a0 100644 --- a/Modules/Api/Http/Controllers/ContentController.php +++ b/Modules/Api/Http/Controllers/ContentController.php @@ -1,4 +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)));}} + +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Modules\Api\Http\Resources\ContentResource; +use Modules\Blog\Models\Blog; +use Modules\News\Models\News; +use Modules\Pages\Models\Page; + +final class ContentController +{ + public function pages(): AnonymousResourceCollection + { + return $this->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/Modules/Api/Http/Controllers/OpenApiController.php b/Modules/Api/Http/Controllers/OpenApiController.php index 7ff703d..f609f27 100644 --- a/Modules/Api/Http/Controllers/OpenApiController.php +++ b/Modules/Api/Http/Controllers/OpenApiController.php @@ -1,4 +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']]]]]]);}} + +final class OpenApiController +{ + public function __invoke(): JsonResponse + { + return response()->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/Modules/Api/Http/Middleware/AuthenticateApiToken.php b/Modules/Api/Http/Middleware/AuthenticateApiToken.php index 7554090..236b1b5 100644 --- a/Modules/Api/Http/Middleware/AuthenticateApiToken.php +++ b/Modules/Api/Http/Middleware/AuthenticateApiToken.php @@ -1,4 +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);}} + +use Closure; +use Illuminate\Http\Request; +use Modules\Api\Models\ApiToken; +use Symfony\Component\HttpFoundation\Response; + +final class AuthenticateApiToken +{ + public function handle(Request $r, Closure $next, ?string $ability = null): Response + { + $plain = $r->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/Modules/Api/Http/Resources/ContentResource.php b/Modules/Api/Http/Resources/ContentResource.php index 9728e78..1ac7b0a 100644 --- a/Modules/Api/Http/Resources/ContentResource.php +++ b/Modules/Api/Http/Resources/ContentResource.php @@ -1,4 +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];}} + +use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\JsonResource; + +final class ContentResource extends JsonResource +{ + public function toArray(Request $r): array + { + return ['id' => $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/Modules/Api/Models/ApiToken.php b/Modules/Api/Models/ApiToken.php index e96ced2..6de3874 100644 --- a/Modules/Api/Models/ApiToken.php +++ b/Modules/Api/Models/ApiToken.php @@ -1,4 +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);} } + +use App\Models\User; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Str; + +final class ApiToken extends Model +{ + protected $fillable = ['user_id', 'name', 'token', 'abilities', 'last_used_at', 'expires_at']; + + protected $hidden = ['token']; + + protected function casts(): array + { + return ['abilities' => '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/Modules/Api/Models/Webhook.php b/Modules/Api/Models/Webhook.php index 58640ca..28d08ef 100644 --- a/Modules/Api/Models/Webhook.php +++ b/Modules/Api/Models/Webhook.php @@ -1,4 +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);}} + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; + +final class Webhook extends Model +{ + protected $fillable = ['site_id', 'name', 'url', 'secret', 'events', 'is_active', 'failure_count', 'last_triggered_at']; + + protected $hidden = ['secret']; + + protected function casts(): array + { + return ['events' => '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/Modules/Api/Models/WebhookDelivery.php b/Modules/Api/Models/WebhookDelivery.php index 5ba8a6e..a07b1a4 100644 --- a/Modules/Api/Models/WebhookDelivery.php +++ b/Modules/Api/Models/WebhookDelivery.php @@ -1,4 +1,21 @@ 'array','delivered_at'=>'datetime','failed_at'=>'datetime'];}public function webhook(): BelongsTo{return $this->belongsTo(Webhook::class);}} + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +final class WebhookDelivery extends Model +{ + protected $fillable = ['webhook_id', 'uuid', 'event', 'payload', 'response_status', 'response_body', 'attempts', 'delivered_at', 'failed_at']; + + protected function casts(): array + { + return ['payload' => 'array', 'delivered_at' => 'datetime', 'failed_at' => 'datetime']; + } + + public function webhook(): BelongsTo + { + return $this->belongsTo(Webhook::class); + } +} diff --git a/Modules/Api/Providers/ApiServiceProvider.php b/Modules/Api/Providers/ApiServiceProvider.php index 864d378..1f1122c 100644 --- a/Modules/Api/Providers/ApiServiceProvider.php +++ b/Modules/Api/Providers/ApiServiceProvider.php @@ -1,5 +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'); } } + +final class ApiServiceProvider extends ServiceProvider +{ + public function register(): void + { + $this->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/Modules/Api/Providers/RouteServiceProvider.php b/Modules/Api/Providers/RouteServiceProvider.php index 8cc3f31..f6699cb 100644 --- a/Modules/Api/Providers/RouteServiceProvider.php +++ b/Modules/Api/Providers/RouteServiceProvider.php @@ -1,5 +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')); } } + +final class RouteServiceProvider extends ServiceProvider +{ + public function boot(): void + { + Route::middleware('api')->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/Modules/Api/Routes/api.php b/Modules/Api/Routes/api.php index 6dc5978..63c733c 100644 --- a/Modules/Api/Routes/api.php +++ b/Modules/Api/Routes/api.php @@ -1,3 +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()); + +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; +use Modules\Api\Http\Controllers\ContentController; +use Modules\Api\Http\Controllers\OpenApiController; +use Modules\Api\Http\Middleware\AuthenticateApiToken; + +Route::get('/openapi.json', OpenApiController::class)->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/Modules/Api/Routes/web.php b/Modules/Api/Routes/web.php index 75532d3..1b2d91a 100644 --- a/Modules/Api/Routes/web.php +++ b/Modules/Api/Routes/web.php @@ -1,3 +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');}); + +use Illuminate\Support\Facades\Route; +use Modules\Api\Http\Controllers\ApiController; + +Route::middleware(['web', 'auth', 'permission:api.manage'])->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/Modules/Api/Services/WebhookDispatcher.php b/Modules/Api/Services/WebhookDispatcher.php index 9e509c3..05da44d 100644 --- a/Modules/Api/Services/WebhookDispatcher.php +++ b/Modules/Api/Services/WebhookDispatcher.php @@ -1,4 +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');}});}} + +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; +use Modules\Api\Models\Webhook; +use Modules\Api\Models\WebhookDelivery; + +final class WebhookDispatcher +{ + public function dispatch(string $event, array $payload, ?int $siteId = null): void + { + Webhook::where('is_active', true)->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/Modules/Api/Tests/Feature/ApiModuleBootTest.php b/Modules/Api/Tests/Feature/ApiModuleBootTest.php index 4ea6229..f3b4f0a 100644 --- a/Modules/Api/Tests/Feature/ApiModuleBootTest.php +++ b/Modules/Api/Tests/Feature/ApiModuleBootTest.php @@ -1,3 +1,13 @@ expect(app()->providerIsLoaded(Modules\Api\Providers\ApiServiceProvider::class))->toBeTrue());it('hashes issued api tokens',function():void{$u=App\Models\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')); +use Modules\Api\Providers\ApiServiceProvider; + +it('boots the api module', fn () => 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/Modules/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php b/Modules/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php index f9c167a..81771b0 100644 --- a/Modules/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php +++ b/Modules/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('audit_logs', function (Blueprint $table): void { + $table->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/Modules/Audit/Http/Controllers/AuditController.php b/Modules/Audit/Http/Controllers/AuditController.php index 0a3944d..f28a59b 100644 --- a/Modules/Audit/Http/Controllers/AuditController.php +++ b/Modules/Audit/Http/Controllers/AuditController.php @@ -1,4 +1,14 @@ AuditLog::query()->latest('created_at')->paginate(50)]); } } + +use App\Http\Controllers\Controller; +use Modules\Audit\Models\AuditLog; + +final class AuditController extends Controller +{ + public function index() + { + return view('audit::index', ['logs' => AuditLog::query()->latest('created_at')->paginate(50)]); + } +} diff --git a/Modules/Audit/Models/AuditLog.php b/Modules/Audit/Models/AuditLog.php index d156350..6dce2ae 100644 --- a/Modules/Audit/Models/AuditLog.php +++ b/Modules/Audit/Models/AuditLog.php @@ -1,3 +1,17 @@ 'array','new_values'=>'array','created_at'=>'datetime']; } } + +namespace Modules\Audit\Models; + +use Illuminate\Database\Eloquent\Model; + +final class AuditLog extends Model +{ + public $timestamps = false; + + protected $guarded = []; + + protected function casts(): array + { + return ['old_values' => 'array', 'new_values' => 'array', 'created_at' => 'datetime']; + } +} diff --git a/Modules/Audit/Routes/web.php b/Modules/Audit/Routes/web.php index f4a649b..f4c314e 100644 --- a/Modules/Audit/Routes/web.php +++ b/Modules/Audit/Routes/web.php @@ -1,3 +1,6 @@ get('administration/audit',[AuditController::class,'index'])->name('administration.audit.index'); + +use Illuminate\Support\Facades\Route; +use Modules\Audit\Http\Controllers\AuditController; + +Route::middleware(['auth', 'verified'])->get('administration/audit', [AuditController::class, 'index'])->name('administration.audit.index'); diff --git a/Modules/Audit/Support/ModuleInfo.php b/Modules/Audit/Support/ModuleInfo.php index 632b84f..96f4251 100644 --- a/Modules/Audit/Support/ModuleInfo.php +++ b/Modules/Audit/Support/ModuleInfo.php @@ -4,6 +4,13 @@ namespace Modules\Audit\Support; final class ModuleInfo { - public static function name(): string { return 'Audit'; } - public static function nameLower(): string { return 'audit'; } + public static function name(): string + { + return 'Audit'; + } + + public static function nameLower(): string + { + return 'audit'; + } } diff --git a/Modules/Authors/Config/config.php b/Modules/Authors/Config/config.php index e09f04e..9255ff8 100644 --- a/Modules/Authors/Config/config.php +++ b/Modules/Authors/Config/config.php @@ -1,2 +1,3 @@ 'Authors']; diff --git a/Modules/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php b/Modules/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php index 1566e6c..fb9ddce 100644 --- a/Modules/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php +++ b/Modules/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('authors', function (Blueprint $table): void { + $table->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/Modules/Authors/Http/Controllers/AuthorController.php b/Modules/Authors/Http/Controllers/AuthorController.php index e065d83..a25fa53 100644 --- a/Modules/Authors/Http/Controllers/AuthorController.php +++ b/Modules/Authors/Http/Controllers/AuthorController.php @@ -1,4 +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; } } + +use App\Http\Controllers\Controller; +use App\Models\User; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\View\View; +use Modules\Authors\Models\Author; + +final class AuthorController extends Controller +{ + public function index(): View + { + return view('authors::admin.index', ['records' => 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/Modules/Authors/Models/Author.php b/Modules/Authors/Models/Author.php index 0b51f11..7fa531e 100644 --- a/Modules/Authors/Models/Author.php +++ b/Modules/Authors/Models/Author.php @@ -1,3 +1,22 @@ 'array','is_active'=>'boolean']; } public function user(): BelongsTo { return $this->belongsTo(User::class); } } + +namespace Modules\Authors\Models; + +use App\Models\User; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +final class Author extends Model +{ + protected $fillable = ['user_id', 'name', 'slug', 'job_title', 'biography', 'avatar_path', 'social_links', 'meta_title', 'meta_description', 'is_active']; + + protected function casts(): array + { + return ['social_links' => 'array', 'is_active' => 'boolean']; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/Modules/Authors/Providers/AuthorsServiceProvider.php b/Modules/Authors/Providers/AuthorsServiceProvider.php index a80374d..1df491a 100644 --- a/Modules/Authors/Providers/AuthorsServiceProvider.php +++ b/Modules/Authors/Providers/AuthorsServiceProvider.php @@ -1,6 +1,9 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('Authors', 'Config/config.php'), 'authors'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Authors', 'Database/Migrations')); diff --git a/Modules/Authors/Providers/RouteServiceProvider.php b/Modules/Authors/Providers/RouteServiceProvider.php index c75b852..f3e7ff9 100644 --- a/Modules/Authors/Providers/RouteServiceProvider.php +++ b/Modules/Authors/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ prefix('administration/authors')->name('administration.authors.')->group(function (): void { Route::resource('/', AuthorController::class)->parameters([''=>'author'])->except('show'); }); + +use Illuminate\Support\Facades\Route; +use Modules\Authors\Http\Controllers\AuthorController; + +Route::middleware(['auth', 'verified'])->prefix('administration/authors')->name('administration.authors.')->group(function (): void { + Route::resource('/', AuthorController::class)->parameters(['' => 'author'])->except('show'); +}); diff --git a/Modules/Authors/Tests/Feature/ModuleBootTest.php b/Modules/Authors/Tests/Feature/ModuleBootTest.php index b391a0e..256a4e6 100644 --- a/Modules/Authors/Tests/Feature/ModuleBootTest.php +++ b/Modules/Authors/Tests/Feature/ModuleBootTest.php @@ -1,6 +1,9 @@ env('AUTOMATION_QUEUE', 'default'), 'max_steps' => (int) env('AUTOMATION_MAX_STEPS', 50), diff --git a/Modules/Automation/Console/DispatchScheduledAutomations.php b/Modules/Automation/Console/DispatchScheduledAutomations.php index 80fd867..b80d5fd 100644 --- a/Modules/Automation/Console/DispatchScheduledAutomations.php +++ b/Modules/Automation/Console/DispatchScheduledAutomations.php @@ -1,15 +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; + $count = 0; + Automation::query()->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/Modules/Automation/Contracts/ActionRegistry.php b/Modules/Automation/Contracts/ActionRegistry.php index 7c7a41e..49c599c 100644 --- a/Modules/Automation/Contracts/ActionRegistry.php +++ b/Modules/Automation/Contracts/ActionRegistry.php @@ -1,8 +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->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']); + $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->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->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->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'); + Schema::dropIfExists('automation_run_logs'); + Schema::dropIfExists('automation_runs'); + Schema::dropIfExists('automation_steps'); + Schema::dropIfExists('automations'); } }; diff --git a/Modules/Automation/Http/Controllers/AutomationApiController.php b/Modules/Automation/Http/Controllers/AutomationApiController.php index f361da5..72701c4 100644 --- a/Modules/Automation/Http/Controllers/AutomationApiController.php +++ b/Modules/Automation/Http/Controllers/AutomationApiController.php @@ -1,16 +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); + abort_unless($automation->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/Modules/Automation/Http/Controllers/AutomationController.php b/Modules/Automation/Http/Controllers/AutomationController.php index bde92c5..38524c9 100644 --- a/Modules/Automation/Http/Controllers/AutomationController.php +++ b/Modules/Automation/Http/Controllers/AutomationController.php @@ -1,5 +1,7 @@ Automation::withCount(['steps','runs'])->latest()->paginate(20),'recentRuns'=>AutomationRun::with('automation')->latest()->limit(15)->get(),'triggers'=>$triggers->all(),'actions'=>$actions->all()]); + return view('automation::admin.index', ['automations' => 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 + + 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.'); + $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 + + 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.'); + $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.'); } - 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/Modules/Automation/Jobs/ExecuteAutomation.php b/Modules/Automation/Jobs/ExecuteAutomation.php index dc46549..b84d34d 100644 --- a/Modules/Automation/Jobs/ExecuteAutomation.php +++ b/Modules/Automation/Jobs/ExecuteAutomation.php @@ -1,5 +1,7 @@ findOrFail($this->runId); $automation=$run->automation; - $run->update(['status'=>'running','started_at'=>now()]); + $run = AutomationRun::with('automation.steps')->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; } + 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()]); } + $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/Modules/Automation/Models/Automation.php b/Modules/Automation/Models/Automation.php index 4f8a82d..0a060ed 100644 --- a/Modules/Automation/Models/Automation.php +++ b/Modules/Automation/Models/Automation.php @@ -1,12 +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'; } + protected $fillable = ['name', 'description', 'trigger_type', 'trigger_config', 'status', 'stop_on_failure', 'run_count', 'last_ran_at', 'next_run_at', 'created_by']; + + protected $casts = ['trigger_config' => '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/Modules/Automation/Models/AutomationRun.php b/Modules/Automation/Models/AutomationRun.php index 62febea..38b5fb5 100644 --- a/Modules/Automation/Models/AutomationRun.php +++ b/Modules/Automation/Models/AutomationRun.php @@ -1,12 +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); } + protected $fillable = ['automation_id', 'execution_id', 'trigger_type', 'payload', 'status', 'current_step', 'error', 'started_at', 'finished_at']; + + protected $casts = ['payload' => '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/Modules/Automation/Models/AutomationRunLog.php b/Modules/Automation/Models/AutomationRunLog.php index 4943c03..3d93839 100644 --- a/Modules/Automation/Models/AutomationRunLog.php +++ b/Modules/Automation/Models/AutomationRunLog.php @@ -1,9 +1,14 @@ 'array','created_at'=>'datetime']; + public $timestamps = false; + + protected $fillable = ['automation_run_id', 'automation_step_id', 'level', 'message', 'context', 'created_at']; + + protected $casts = ['context' => 'array', 'created_at' => 'datetime']; } diff --git a/Modules/Automation/Models/AutomationStep.php b/Modules/Automation/Models/AutomationStep.php index 620a5c8..2627289 100644 --- a/Modules/Automation/Models/AutomationStep.php +++ b/Modules/Automation/Models/AutomationStep.php @@ -1,10 +1,18 @@ 'array','is_enabled'=>'boolean']; - public function automation(): BelongsTo { return $this->belongsTo(Automation::class); } + protected $fillable = ['automation_id', 'position', 'action_type', 'name', 'configuration', 'is_enabled']; + + protected $casts = ['configuration' => 'array', 'is_enabled' => 'boolean']; + + public function automation(): BelongsTo + { + return $this->belongsTo(Automation::class); + } } diff --git a/Modules/Automation/Providers/AutomationServiceProvider.php b/Modules/Automation/Providers/AutomationServiceProvider.php index 4a828c9..b4c3b7c 100644 --- a/Modules/Automation/Providers/AutomationServiceProvider.php +++ b/Modules/Automation/Providers/AutomationServiceProvider.php @@ -1,11 +1,14 @@ 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]); + if ($this->app->runningInConsole()) { + $this->commands([DispatchScheduledAutomations::class]); + } } } diff --git a/Modules/Automation/Providers/RouteServiceProvider.php b/Modules/Automation/Providers/RouteServiceProvider.php index 8f21665..f03573a 100644 --- a/Modules/Automation/Providers/RouteServiceProvider.php +++ b/Modules/Automation/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ middleware('throttle:60,1')->name('api.automations.trigger'); + +Route::post('/automations/{automation}/trigger', [AutomationApiController::class, 'trigger'])->middleware('throttle:60,1')->name('api.automations.trigger'); diff --git a/Modules/Automation/Routes/web.php b/Modules/Automation/Routes/web.php index d6e2c87..342d7c0 100644 --- a/Modules/Automation/Routes/web.php +++ b/Modules/Automation/Routes/web.php @@ -1,11 +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'); + +Route::prefix('administration/automation')->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/Modules/Automation/Services/AutomationActionRegistry.php b/Modules/Automation/Services/AutomationActionRegistry.php index 1e941a5..b8e7f2c 100644 --- a/Modules/Automation/Services/AutomationActionRegistry.php +++ b/Modules/Automation/Services/AutomationActionRegistry.php @@ -1,50 +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']], + 'log' => ['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}]."), + $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 + + private function log(array $config, array $payload): array { - $message=(string)($config['message']??'Automation executed'); Log::info($message,$payload); return ['message'=>$message]; + $message = (string) ($config['message'] ?? 'Automation executed'); + Log::info($message, $payload); + + return ['message' => $message]; } - private function webhook(array $config,array $payload): array + + 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()]; + $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 + + 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]; + $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]; + $seconds = max(0, min(30, (int) ($config['seconds'] ?? 1))); + if ($seconds > 0) { + sleep($seconds); + } + +return ['seconds' => $seconds]; } } diff --git a/Modules/Automation/Services/AutomationRunner.php b/Modules/Automation/Services/AutomationRunner.php index 70e7529..254ba8a 100644 --- a/Modules/Automation/Services/AutomationRunner.php +++ b/Modules/Automation/Services/AutomationRunner.php @@ -1,28 +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')); + abort_unless($automation->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 + + 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++; } } + $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 + + 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; + $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/Modules/Automation/Services/AutomationTriggerRegistry.php b/Modules/Automation/Services/AutomationTriggerRegistry.php index ed6f884..c0716f4 100644 --- a/Modules/Automation/Services/AutomationTriggerRegistry.php +++ b/Modules/Automation/Services/AutomationTriggerRegistry.php @@ -1,16 +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.'], + 'manual' => ['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()); } + + public function has(string $type): bool + { + return array_key_exists($type, $this->all()); + } } diff --git a/Modules/Automation/Tests/Feature/ModuleBootTest.php b/Modules/Automation/Tests/Feature/ModuleBootTest.php index cf6227a..984262f 100644 --- a/Modules/Automation/Tests/Feature/ModuleBootTest.php +++ b/Modules/Automation/Tests/Feature/ModuleBootTest.php @@ -1,11 +1,16 @@ assertTrue(class_exists(\Modules\Automation\Providers\AutomationServiceProvider::class)); - $this->assertTrue(app()->bound(\Modules\Automation\Contracts\ActionRegistry::class)); + $this->assertTrue(class_exists(AutomationServiceProvider::class)); + $this->assertTrue(app()->bound(ActionRegistry::class)); } } diff --git a/Modules/Banners/Http/Controllers/BannerController.php b/Modules/Banners/Http/Controllers/BannerController.php index 409dcbc..2650090 100644 --- a/Modules/Banners/Http/Controllers/BannerController.php +++ b/Modules/Banners/Http/Controllers/BannerController.php @@ -10,18 +10,51 @@ use Modules\Banners\Models\Banner; final class BannerController extends Controller { - public function index(): View { return view('banners::admin.index', ['records' => 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'], + public function index(): View + { + return view('banners::admin.index', ['records' => 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('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'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + Banner::create($data); + + return redirect()->route('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('banners.index')->with('status', 'Banner updated.'); } - public function destroy(Banner $banner): RedirectResponse { $banner->delete(); return back()->with('status', 'Banner deleted.'); } + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + $banner->update($data); + + return redirect()->route('banners.index')->with('status', 'Banner updated.'); + } + + public function destroy(Banner $banner): RedirectResponse + { + $banner->delete(); + + return back()->with('status', 'Banner deleted.'); + } } diff --git a/Modules/Banners/Models/Banner.php b/Modules/Banners/Models/Banner.php index 4257112..7ba1279 100644 --- a/Modules/Banners/Models/Banner.php +++ b/Modules/Banners/Models/Banner.php @@ -9,11 +9,15 @@ use Illuminate\Database\Eloquent\SoftDeletes; final class Banner extends Model { use HasFactory, SoftDeletes; - protected $table = 'banners'; - protected $fillable = ['title', 'content', 'link_url', 'position', 'enabled']; - - protected function casts(): array { return [ - 'enabled' => 'boolean' - ]; } + protected $table = 'banners'; + + protected $fillable = ['title', 'content', 'link_url', 'position', 'enabled']; + + protected function casts(): array + { + return [ + 'enabled' => 'boolean', + ]; + } } diff --git a/Modules/Banners/Support/ModuleInfo.php b/Modules/Banners/Support/ModuleInfo.php index 45c47a6..09e1bda 100644 --- a/Modules/Banners/Support/ModuleInfo.php +++ b/Modules/Banners/Support/ModuleInfo.php @@ -2,4 +2,15 @@ namespace Modules\Banners\Support; -final class ModuleInfo { public static function name(): string { return 'Banners'; } public static function nameLower(): string { return 'banners'; } } +final class ModuleInfo +{ + public static function name(): string + { + return 'Banners'; + } + + public static function nameLower(): string + { + return 'banners'; + } +} diff --git a/Modules/Banners/Tests/Feature/ModuleBootTest.php b/Modules/Banners/Tests/Feature/ModuleBootTest.php index cdea519..6f720b6 100644 --- a/Modules/Banners/Tests/Feature/ModuleBootTest.php +++ b/Modules/Banners/Tests/Feature/ModuleBootTest.php @@ -2,4 +2,6 @@ use Illuminate\Support\Facades\Route; -it('registers the Banners administration route', function (): void { expect(Route::has('administration.banners.index'))->toBeTrue(); }); +it('registers the Banners administration route', function (): void { + expect(Route::has('administration.banners.index'))->toBeTrue(); +}); diff --git a/Modules/Blog/Config/config.php b/Modules/Blog/Config/config.php index e68ab05..97da7a5 100644 --- a/Modules/Blog/Config/config.php +++ b/Modules/Blog/Config/config.php @@ -1,2 +1,3 @@ 'Blog']; diff --git a/Modules/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php b/Modules/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php index 0e1305b..30684a4 100644 --- a/Modules/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php +++ b/Modules/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('blog_posts', function (Blueprint $table): void { + $table->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/Modules/Blog/Http/Controllers/BlogController.php b/Modules/Blog/Http/Controllers/BlogController.php index c5f1de1..0e96878 100644 --- a/Modules/Blog/Http/Controllers/BlogController.php +++ b/Modules/Blog/Http/Controllers/BlogController.php @@ -1,4 +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; } } + +use App\Http\Controllers\Controller; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\View\View; +use Modules\Authors\Models\Author; +use Modules\Blog\Models\Blog; +use Modules\Categories\Models\Category; +use Modules\Tags\Models\Tag; + +final class BlogController extends Controller +{ + public function index(): View + { + return view('blog::admin.index', ['records' => 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/Modules/Blog/Http/Controllers/PublicBlogController.php b/Modules/Blog/Http/Controllers/PublicBlogController.php index 84600d4..6fb2096 100644 --- a/Modules/Blog/Http/Controllers/PublicBlogController.php +++ b/Modules/Blog/Http/Controllers/PublicBlogController.php @@ -1,3 +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()]); } } + +namespace Modules\Blog\Http\Controllers; + +use App\Http\Controllers\Controller; +use Illuminate\View\View; +use Modules\Blog\Models\Blog; + +final class PublicBlogController extends Controller +{ + public function index(): View + { + return view('blog::public.index', ['records' => 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/Modules/Blog/Models/Blog.php b/Modules/Blog/Models/Blog.php index 4b08934..6f58ea0 100644 --- a/Modules/Blog/Models/Blog.php +++ b/Modules/Blog/Models/Blog.php @@ -1,5 +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())); } } +use Modules\Tags\Models\Tag; +use Modules\Workflow\Concerns\HasWorkflow; + +final class Blog extends Model +{ + use BelongsToSites, HasTranslations, HasWorkflow; + + protected $table = 'blog_posts'; + + protected $fillable = ['author_id', 'category_id', 'title', 'slug', 'excerpt', 'content', 'featured_image', 'status', 'is_featured', 'published_at', 'expires_at', 'meta_title', 'meta_description']; + + protected function casts(): array + { + return ['is_featured' => '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/Modules/Blog/Providers/BlogServiceProvider.php b/Modules/Blog/Providers/BlogServiceProvider.php index 7f1ff3d..74e8bcf 100644 --- a/Modules/Blog/Providers/BlogServiceProvider.php +++ b/Modules/Blog/Providers/BlogServiceProvider.php @@ -1,6 +1,9 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('Blog', 'Config/config.php'), 'blog'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Blog', 'Database/Migrations')); diff --git a/Modules/Blog/Providers/RouteServiceProvider.php b/Modules/Blog/Providers/RouteServiceProvider.php index 38fcab9..6c7b0c0 100644 --- a/Modules/Blog/Providers/RouteServiceProvider.php +++ b/Modules/Blog/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ prefix('administration/blog')->name('administration.blog.')->group(function (): void { Route::resource('/', BlogController::class)->parameters([''=>'blog_post'])->except('show'); }); -Route::get('/blog',[PublicBlogController::class,'index'])->name('blog.public.index'); Route::get('/blog/{slug}',[PublicBlogController::class,'show'])->name('blog.public.show'); + +use Illuminate\Support\Facades\Route; +use Modules\Blog\Http\Controllers\BlogController; +use Modules\Blog\Http\Controllers\PublicBlogController; + +Route::middleware(['auth', 'verified'])->prefix('administration/blog')->name('administration.blog.')->group(function (): void { + Route::resource('/', BlogController::class)->parameters(['' => 'blog_post'])->except('show'); +}); +Route::get('/blog', [PublicBlogController::class, 'index'])->name('blog.public.index'); +Route::get('/blog/{slug}', [PublicBlogController::class, 'show'])->name('blog.public.show'); diff --git a/Modules/Builder/Components/ContentComponent.php b/Modules/Builder/Components/ContentComponent.php index c7380e6..63dc588 100644 --- a/Modules/Builder/Components/ContentComponent.php +++ b/Modules/Builder/Components/ContentComponent.php @@ -1,12 +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']??''); } + public function type(): string + { + return 'content'; + } + + public function label(): string + { + return 'Rich Content'; + } + + public function category(): string + { + return 'Content'; + } + + public function schema(): array + { + return ['content' => ['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/Modules/Builder/Components/HeadingComponent.php b/Modules/Builder/Components/HeadingComponent.php index b1ed9b0..e619242 100644 --- a/Modules/Builder/Components/HeadingComponent.php +++ b/Modules/Builder/Components/HeadingComponent.php @@ -1,12 +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']??'').''; } + public function type(): string + { + return 'heading'; + } + + public function label(): string + { + return 'Heading'; + } + + public function category(): string + { + return 'Typography'; + } + + public function schema(): array + { + return ['text' => ['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/Modules/Builder/Components/HeroComponent.php b/Modules/Builder/Components/HeroComponent.php index cabc7a4..f017dea 100644 --- a/Modules/Builder/Components/HeroComponent.php +++ b/Modules/Builder/Components/HeroComponent.php @@ -1,12 +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(); } + public function type(): string + { + return 'hero'; + } + + public function label(): string + { + return 'Hero'; + } + + public function category(): string + { + return 'Content'; + } + + public function schema(): array + { + return ['heading' => ['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/Modules/Builder/Config/config.php b/Modules/Builder/Config/config.php index d676191..b3a8362 100644 --- a/Modules/Builder/Config/config.php +++ b/Modules/Builder/Config/config.php @@ -1,7 +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'], + 'allowed_types' => ['page', 'form', 'dashboard', 'report', 'menu', 'email', 'query', 'workflow'], ]; diff --git a/Modules/Builder/Contracts/BuilderComponent.php b/Modules/Builder/Contracts/BuilderComponent.php index be6dfc2..e16fad7 100644 --- a/Modules/Builder/Contracts/BuilderComponent.php +++ b/Modules/Builder/Contracts/BuilderComponent.php @@ -1,11 +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->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'); + $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']); + $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(); + $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'); } + + public function down(): void + { + Schema::dropIfExists('builder_templates'); + Schema::dropIfExists('builder_document_versions'); + Schema::dropIfExists('builder_documents'); + } }; diff --git a/Modules/Builder/Http/Controllers/BuilderApiController.php b/Modules/Builder/Http/Controllers/BuilderApiController.php index d962c79..57296aa 100644 --- a/Modules/Builder/Http/Controllers/BuilderApiController.php +++ b/Modules/Builder/Http/Controllers/BuilderApiController.php @@ -1,13 +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 ?? [])]); } + public function components(BuilderRegistry $registry): JsonResponse + { + return response()->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/Modules/Builder/Http/Controllers/BuilderController.php b/Modules/Builder/Http/Controllers/BuilderController.php index 386adde..70443cc 100644 --- a/Modules/Builder/Http/Controllers/BuilderController.php +++ b/Modules/Builder/Http/Controllers/BuilderController.php @@ -1,5 +1,7 @@ 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')); + $type = $request->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.'); } + + 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]; + $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/Modules/Builder/Models/BuilderDocument.php b/Modules/Builder/Models/BuilderDocument.php index d8162be..edb53ad 100644 --- a/Modules/Builder/Models/BuilderDocument.php +++ b/Modules/Builder/Models/BuilderDocument.php @@ -1,10 +1,18 @@ 'array','settings'=>'array','published_at'=>'datetime']; - public function versions(): HasMany { return $this->hasMany(BuilderDocumentVersion::class); } + protected $fillable = ['site_id', 'type', 'name', 'slug', 'status', 'version', 'document', 'settings', 'buildable_type', 'buildable_id', 'created_by', 'updated_by', 'published_at']; + + protected $casts = ['document' => 'array', 'settings' => 'array', 'published_at' => 'datetime']; + + public function versions(): HasMany + { + return $this->hasMany(BuilderDocumentVersion::class); + } } diff --git a/Modules/Builder/Models/BuilderDocumentVersion.php b/Modules/Builder/Models/BuilderDocumentVersion.php index 6ff29f4..30d6a78 100644 --- a/Modules/Builder/Models/BuilderDocumentVersion.php +++ b/Modules/Builder/Models/BuilderDocumentVersion.php @@ -1,8 +1,12 @@ 'array','settings'=>'array']; + protected $fillable = ['builder_document_id', 'version', 'document', 'settings', 'created_by']; + + protected $casts = ['document' => 'array', 'settings' => 'array']; } diff --git a/Modules/Builder/Models/BuilderTemplate.php b/Modules/Builder/Models/BuilderTemplate.php index e857e66..68520d6 100644 --- a/Modules/Builder/Models/BuilderTemplate.php +++ b/Modules/Builder/Models/BuilderTemplate.php @@ -1,8 +1,12 @@ 'array','settings'=>'array','is_system'=>'boolean','is_active'=>'boolean']; + protected $fillable = ['type', 'name', 'slug', 'description', 'document', 'settings', 'is_system', 'is_active']; + + protected $casts = ['document' => 'array', 'settings' => 'array', 'is_system' => 'boolean', 'is_active' => 'boolean']; } diff --git a/Modules/Builder/Providers/BuilderServiceProvider.php b/Modules/Builder/Providers/BuilderServiceProvider.php index b6059c1..d1d9c21 100644 --- a/Modules/Builder/Providers/BuilderServiceProvider.php +++ b/Modules/Builder/Providers/BuilderServiceProvider.php @@ -1,7 +1,10 @@ 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')); diff --git a/Modules/Builder/Providers/RouteServiceProvider.php b/Modules/Builder/Providers/RouteServiceProvider.php index 83469a6..e63c673 100644 --- a/Modules/Builder/Providers/RouteServiceProvider.php +++ b/Modules/Builder/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ 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'); + 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/Modules/Builder/Routes/web.php b/Modules/Builder/Routes/web.php index cd8fbe0..ed43359 100644 --- a/Modules/Builder/Routes/web.php +++ b/Modules/Builder/Routes/web.php @@ -1,11 +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'); + +Route::middleware(['auth', 'permission:builder.view'])->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/Modules/Builder/Services/BuilderDocumentService.php b/Modules/Builder/Services/BuilderDocumentService.php index 430b7ec..29f2368 100644 --- a/Modules/Builder/Services/BuilderDocumentService.php +++ b/Modules/Builder/Services/BuilderDocumentService.php @@ -1,16 +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(); + return DB::transaction(function () use ($document, $payload, $userId) { + if ($document->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/Modules/Builder/Services/BuilderRegistry.php b/Modules/Builder/Services/BuilderRegistry.php index b561a89..0e63193 100644 --- a/Modules/Builder/Services/BuilderRegistry.php +++ b/Modules/Builder/Services/BuilderRegistry.php @@ -1,24 +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()]; + foreach ([HeroComponent::class, HeadingComponent::class, ContentComponent::class] as $component) { + $this->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); } - 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/Modules/Builder/Services/BuilderRenderer.php b/Modules/Builder/Services/BuilderRenderer.php index 7b76e87..2b8a647 100644 --- a/Modules/Builder/Services/BuilderRenderer.php +++ b/Modules/Builder/Services/BuilderRenderer.php @@ -1,13 +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); + return collect($document['nodes'] ?? [])->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/Modules/Builder/Tests/Feature/BuilderModuleTest.php b/Modules/Builder/Tests/Feature/BuilderModuleTest.php index 3ed3a78..0844567 100644 --- a/Modules/Builder/Tests/Feature/BuilderModuleTest.php +++ b/Modules/Builder/Tests/Feature/BuilderModuleTest.php @@ -1,13 +1,16 @@ components(); + $components = app(BuilderRegistry::class)->components(); $this->assertNotEmpty($components); - $this->assertContains('hero',array_column($components,'type')); + $this->assertContains('hero', array_column($components, 'type')); } } diff --git a/Modules/CRM/Config/config.php b/Modules/CRM/Config/config.php index f4ee058..c94cac3 100644 --- a/Modules/CRM/Config/config.php +++ b/Modules/CRM/Config/config.php @@ -1,2 +1,3 @@ 'CRM']; diff --git a/Modules/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php b/Modules/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php index 25e7049..cf3bc64 100644 --- a/Modules/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php +++ b/Modules/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php @@ -1,9 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('crm_companies', function (Blueprint $t): void { + $t->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/Modules/CRM/Http/Controllers/CRMController.php b/Modules/CRM/Http/Controllers/CRMController.php index c3ab23b..0d85cd6 100644 --- a/Modules/CRM/Http/Controllers/CRMController.php +++ b/Modules/CRM/Http/Controllers/CRMController.php @@ -1,11 +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.'); } + +use App\Http\Controllers\Controller; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\View\View; +use Modules\CRM\Models\Company; +use Modules\CRM\Models\Contact; +use Modules\CRM\Models\Lead; +use Modules\CRM\Models\Opportunity; + +final class CRMController extends Controller +{ + public function index(): View + { + return view('crm::admin.index', ['companies' => 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/Modules/CRM/Models/Activity.php b/Modules/CRM/Models/Activity.php index 6574a76..1a2e8b6 100644 --- a/Modules/CRM/Models/Activity.php +++ b/Modules/CRM/Models/Activity.php @@ -1,3 +1,23 @@ 'datetime','completed_at'=>'datetime']; } public function subject(): MorphTo { return $this->morphTo(); } } + +namespace Modules\CRM\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphTo; + +final class Activity extends Model +{ + protected $table = 'crm_activities'; + + protected $fillable = ['subject_type', 'subject_id', 'user_id', 'type', 'title', 'description', 'due_at', 'completed_at']; + + protected function casts(): array + { + return ['due_at' => 'datetime', 'completed_at' => 'datetime']; + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/Modules/CRM/Models/Company.php b/Modules/CRM/Models/Company.php index 2eab9f6..76bcf10 100644 --- a/Modules/CRM/Models/Company.php +++ b/Modules/CRM/Models/Company.php @@ -1,3 +1,23 @@ 'boolean']; } public function contacts(): HasMany { return $this->hasMany(Contact::class); } } + +namespace Modules\CRM\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; + +final class Company extends Model +{ + protected $table = 'crm_companies'; + + protected $fillable = ['name', 'website', 'phone', 'email', 'industry', 'address', 'notes', 'is_active']; + + protected function casts(): array + { + return ['is_active' => 'boolean']; + } + + public function contacts(): HasMany + { + return $this->hasMany(Contact::class); + } +} diff --git a/Modules/CRM/Models/Contact.php b/Modules/CRM/Models/Contact.php index 0a8f29a..e1f08cb 100644 --- a/Modules/CRM/Models/Contact.php +++ b/Modules/CRM/Models/Contact.php @@ -1,3 +1,28 @@ 'boolean']; } public function company(): BelongsTo { return $this->belongsTo(Company::class); } public function getNameAttribute(): string { return trim($this->first_name.' '.$this->last_name); } } + +namespace Modules\CRM\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +final class Contact extends Model +{ + protected $table = 'crm_contacts'; + + protected $fillable = ['company_id', 'first_name', 'last_name', 'email', 'phone', 'job_title', 'notes', 'is_active']; + + protected function casts(): array + { + return ['is_active' => '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/Modules/CRM/Models/Lead.php b/Modules/CRM/Models/Lead.php index 93c50c1..571b5dd 100644 --- a/Modules/CRM/Models/Lead.php +++ b/Modules/CRM/Models/Lead.php @@ -1,3 +1,23 @@ 'decimal:2','next_action_at'=>'datetime']; } public function contact(): BelongsTo { return $this->belongsTo(Contact::class); } } + +namespace Modules\CRM\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +final class Lead extends Model +{ + protected $table = 'crm_leads'; + + protected $fillable = ['contact_id', 'title', 'source', 'status', 'estimated_value', 'notes', 'next_action_at']; + + protected function casts(): array + { + return ['estimated_value' => 'decimal:2', 'next_action_at' => 'datetime']; + } + + public function contact(): BelongsTo + { + return $this->belongsTo(Contact::class); + } +} diff --git a/Modules/CRM/Models/Opportunity.php b/Modules/CRM/Models/Opportunity.php index fbe11ed..6cd7884 100644 --- a/Modules/CRM/Models/Opportunity.php +++ b/Modules/CRM/Models/Opportunity.php @@ -1,3 +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); } } + +namespace Modules\CRM\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +final class Opportunity extends Model +{ + protected $table = 'crm_opportunities'; + + protected $fillable = ['company_id', 'contact_id', 'name', 'stage', 'value', 'probability', 'expected_close_at', 'notes']; + + protected function casts(): array + { + return ['value' => '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/Modules/CRM/Providers/CRMServiceProvider.php b/Modules/CRM/Providers/CRMServiceProvider.php index c987cf0..ba46a79 100644 --- a/Modules/CRM/Providers/CRMServiceProvider.php +++ b/Modules/CRM/Providers/CRMServiceProvider.php @@ -1,6 +1,9 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('CRM', 'Config/config.php'), 'crm'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('CRM', 'Database/Migrations')); diff --git a/Modules/CRM/Providers/RouteServiceProvider.php b/Modules/CRM/Providers/RouteServiceProvider.php index 58e1855..94e56b2 100644 --- a/Modules/CRM/Providers/RouteServiceProvider.php +++ b/Modules/CRM/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ 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'); }); + +use Illuminate\Support\Facades\Route; +use Modules\CRM\Http\Controllers\CRMController; + +Route::middleware(['auth', 'verified'])->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/Modules/CRM/Tests/Feature/ModuleBootTest.php b/Modules/CRM/Tests/Feature/ModuleBootTest.php index ad355a2..3bbcf6f 100644 --- a/Modules/CRM/Tests/Feature/ModuleBootTest.php +++ b/Modules/CRM/Tests/Feature/ModuleBootTest.php @@ -1,6 +1,9 @@ 'Careers']; diff --git a/Modules/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php b/Modules/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php index a0a1cf6..c8627af 100644 --- a/Modules/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php +++ b/Modules/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('career_jobs', function (Blueprint $t): void { + $t->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/Modules/Careers/Http/Controllers/CareerController.php b/Modules/Careers/Http/Controllers/CareerController.php index 9fce28b..d9c2bad 100644 --- a/Modules/Careers/Http/Controllers/CareerController.php +++ b/Modules/Careers/Http/Controllers/CareerController.php @@ -1,3 +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.'); } } + +namespace Modules\Careers\Http\Controllers; + +use App\Http\Controllers\Controller; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\View\View; +use Modules\Careers\Models\Application; +use Modules\Careers\Models\Job; + +final class CareerController extends Controller +{ + public function index(): View + { + return view('careers::admin.index', ['jobs' => 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/Modules/Careers/Models/Application.php b/Modules/Careers/Models/Application.php index dcdf3c1..e85d73e 100644 --- a/Modules/Careers/Models/Application.php +++ b/Modules/Careers/Models/Application.php @@ -1,3 +1,12 @@ 'datetime','closes_at'=>'datetime']; } public function applications(): HasMany { return $this->hasMany(Application::class); } } + +namespace Modules\Careers\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; + +final class Job extends Model +{ + protected $table = 'career_jobs'; + + protected $fillable = ['title', 'slug', 'department', 'location', 'employment_type', 'description', 'status', 'published_at', 'closes_at']; + + protected function casts(): array + { + return ['published_at' => 'datetime', 'closes_at' => 'datetime']; + } + + public function applications(): HasMany + { + return $this->hasMany(Application::class); + } +} diff --git a/Modules/Careers/Providers/CareersServiceProvider.php b/Modules/Careers/Providers/CareersServiceProvider.php index c730a60..5f4cfff 100644 --- a/Modules/Careers/Providers/CareersServiceProvider.php +++ b/Modules/Careers/Providers/CareersServiceProvider.php @@ -1,6 +1,9 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('Careers', 'Config/config.php'), 'careers'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Careers', 'Database/Migrations')); diff --git a/Modules/Careers/Providers/RouteServiceProvider.php b/Modules/Careers/Providers/RouteServiceProvider.php index 5646e53..4e15858 100644 --- a/Modules/Careers/Providers/RouteServiceProvider.php +++ b/Modules/Careers/Providers/RouteServiceProvider.php @@ -1,7 +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'); }); + +use Illuminate\Support\Facades\Route; +use Modules\Careers\Http\Controllers\CareerController; + +Route::middleware(['auth', 'verified'])->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/Modules/Careers/Tests/Feature/ModuleBootTest.php b/Modules/Careers/Tests/Feature/ModuleBootTest.php index afcdaf3..28b7a14 100644 --- a/Modules/Careers/Tests/Feature/ModuleBootTest.php +++ b/Modules/Careers/Tests/Feature/ModuleBootTest.php @@ -1,6 +1,9 @@ 'Categories']; diff --git a/Modules/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php b/Modules/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php index 28e530b..9796bc5 100644 --- a/Modules/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php +++ b/Modules/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php @@ -1,8 +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'); } + +return new class extends Migration +{ + public function up(): void + { + Schema::create('categories', function (Blueprint $table): void { + $table->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/Modules/Categories/Http/Controllers/CategoryController.php b/Modules/Categories/Http/Controllers/CategoryController.php index e40003c..feca75e 100644 --- a/Modules/Categories/Http/Controllers/CategoryController.php +++ b/Modules/Categories/Http/Controllers/CategoryController.php @@ -1,17 +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; } + public function index(): View + { + return view('categories::admin.index', ['records' => 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/Modules/Categories/Models/Category.php b/Modules/Categories/Models/Category.php index afe2af0..8835411 100644 --- a/Modules/Categories/Models/Category.php +++ b/Modules/Categories/Models/Category.php @@ -1,12 +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'); } + protected $fillable = ['parent_id', 'name', 'slug', 'description', 'type', 'sort_order', 'is_active']; + + protected function casts(): array + { + return ['is_active' => '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/Modules/Categories/Providers/CategoriesServiceProvider.php b/Modules/Categories/Providers/CategoriesServiceProvider.php index 4ce9af4..8a20b37 100644 --- a/Modules/Categories/Providers/CategoriesServiceProvider.php +++ b/Modules/Categories/Providers/CategoriesServiceProvider.php @@ -1,6 +1,9 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('Categories', 'Config/config.php'), 'categories'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Categories', 'Database/Migrations')); diff --git a/Modules/Categories/Providers/RouteServiceProvider.php b/Modules/Categories/Providers/RouteServiceProvider.php index 5b0de5b..1423c92 100644 --- a/Modules/Categories/Providers/RouteServiceProvider.php +++ b/Modules/Categories/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ prefix('administration/categories')->name('administration.categories.')->group(function (): void { Route::resource('/', CategoryController::class)->parameters([''=>'category'])->except('show'); }); + +Route::middleware(['auth', 'verified'])->prefix('administration/categories')->name('administration.categories.')->group(function (): void { + Route::resource('/', CategoryController::class)->parameters(['' => 'category'])->except('show'); +}); diff --git a/Modules/Categories/Tests/Feature/ModuleBootTest.php b/Modules/Categories/Tests/Feature/ModuleBootTest.php index 827a912..479e0ae 100644 --- a/Modules/Categories/Tests/Feature/ModuleBootTest.php +++ b/Modules/Categories/Tests/Feature/ModuleBootTest.php @@ -1,6 +1,9 @@ each(fn($check)=>$runner->run($check)); $this->info('Health checks completed.'); return self::SUCCESS; } } + +final class CloudDevOpsHealthCommand extends Command +{ + protected $signature = 'cloud-devops:health'; + + protected $description = 'Run all active deployment health checks'; + + public function handle(HealthCheckRunner $runner): int + { + DeploymentHealthCheck::query()->each(fn ($check) => $runner->run($check)); + $this->info('Health checks completed.'); + + return self::SUCCESS; + } +} diff --git a/Modules/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php b/Modules/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php index c709989..4309b43 100644 --- a/Modules/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php +++ b/Modules/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php @@ -1,5 +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; } } + +final class CloudDevOpsPruneCommand extends Command +{ + protected $signature = 'cloud-devops:prune {--days=}'; + + protected $description = 'Prune old deployment release history'; + + public function handle(): int + { + $days = (int) ($this->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/Modules/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php b/Modules/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php index abcfa5f..9b9d98e 100644 --- a/Modules/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php +++ b/Modules/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php @@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('deployment_environments', function (Blueprint $table): void { diff --git a/Modules/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php b/Modules/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php index 806ac3d..54897f1 100644 --- a/Modules/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php +++ b/Modules/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php @@ -1,7 +1,21 @@ json(['data'=>DeploymentEnvironment::withCount(['targets','pipelines','releases'])->get()]); } public function releases(): JsonResponse { return response()->json(['data'=>DeploymentRelease::with(['environment','steps'])->latest()->paginate()]); } } + +final class CloudDevOpsApiController extends Controller +{ + public function environments(): JsonResponse + { + return response()->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/Modules/CloudDevOps/Http/Controllers/CloudDevOpsController.php b/Modules/CloudDevOps/Http/Controllers/CloudDevOpsController.php index ac3b1bc..dcc212b 100644 --- a/Modules/CloudDevOps/Http/Controllers/CloudDevOpsController.php +++ b/Modules/CloudDevOps/Http/Controllers/CloudDevOpsController.php @@ -26,22 +26,25 @@ final class CloudDevOpsController extends Controller 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']); + $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']); + $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/Modules/CloudDevOps/Models/DeploymentEnvironment.php b/Modules/CloudDevOps/Models/DeploymentEnvironment.php index bc6afd0..55bc24b 100644 --- a/Modules/CloudDevOps/Models/DeploymentEnvironment.php +++ b/Modules/CloudDevOps/Models/DeploymentEnvironment.php @@ -8,9 +8,29 @@ use Illuminate\Database\Eloquent\Relations\HasMany; final class DeploymentEnvironment extends Model { protected $guarded = []; - protected function casts(): array { return ['variables' => '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'); } + + protected function casts(): array + { + return ['variables' => '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/Modules/CloudDevOps/Models/DeploymentHealthCheck.php b/Modules/CloudDevOps/Models/DeploymentHealthCheck.php index ec35a73..2ff53fb 100644 --- a/Modules/CloudDevOps/Models/DeploymentHealthCheck.php +++ b/Modules/CloudDevOps/Models/DeploymentHealthCheck.php @@ -1,4 +1,15 @@ 'datetime']; } } + +final class DeploymentHealthCheck extends Model +{ + protected $guarded = []; + + protected function casts(): array + { + return ['checked_at' => 'datetime']; + } +} diff --git a/Modules/CloudDevOps/Models/DeploymentPipeline.php b/Modules/CloudDevOps/Models/DeploymentPipeline.php index 904bfc2..1aa3ad2 100644 --- a/Modules/CloudDevOps/Models/DeploymentPipeline.php +++ b/Modules/CloudDevOps/Models/DeploymentPipeline.php @@ -1,4 +1,15 @@ 'array', 'requires_approval' => 'boolean', 'is_active' => 'boolean']; } } + +final class DeploymentPipeline extends Model +{ + protected $guarded = []; + + protected function casts(): array + { + return ['steps' => 'array', 'requires_approval' => 'boolean', 'is_active' => 'boolean']; + } +} diff --git a/Modules/CloudDevOps/Models/DeploymentRelease.php b/Modules/CloudDevOps/Models/DeploymentRelease.php index 4faee10..5eca011 100644 --- a/Modules/CloudDevOps/Models/DeploymentRelease.php +++ b/Modules/CloudDevOps/Models/DeploymentRelease.php @@ -1,6 +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'); } } + +final class DeploymentRelease extends Model +{ + protected $guarded = []; + + protected function casts(): array + { + return ['metadata' => '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/Modules/CloudDevOps/Models/DeploymentRollback.php b/Modules/CloudDevOps/Models/DeploymentRollback.php index 370b8cb..8c4cb66 100644 --- a/Modules/CloudDevOps/Models/DeploymentRollback.php +++ b/Modules/CloudDevOps/Models/DeploymentRollback.php @@ -1,4 +1,10 @@ 'datetime']; } } + +final class DeploymentSecret extends Model +{ + protected $guarded = []; + + protected function casts(): array + { + return ['rotated_at' => 'datetime']; + } +} diff --git a/Modules/CloudDevOps/Models/DeploymentStep.php b/Modules/CloudDevOps/Models/DeploymentStep.php index 514faaa..fc2e05b 100644 --- a/Modules/CloudDevOps/Models/DeploymentStep.php +++ b/Modules/CloudDevOps/Models/DeploymentStep.php @@ -1,4 +1,15 @@ 'datetime', 'completed_at' => 'datetime']; } } + +final class DeploymentStep extends Model +{ + protected $guarded = []; + + protected function casts(): array + { + return ['started_at' => 'datetime', 'completed_at' => 'datetime']; + } +} diff --git a/Modules/CloudDevOps/Models/DeploymentTarget.php b/Modules/CloudDevOps/Models/DeploymentTarget.php index 86efbd3..d5de432 100644 --- a/Modules/CloudDevOps/Models/DeploymentTarget.php +++ b/Modules/CloudDevOps/Models/DeploymentTarget.php @@ -1,4 +1,15 @@ 'encrypted:array', 'is_active' => 'boolean']; } } + +final class DeploymentTarget extends Model +{ + protected $guarded = []; + + protected function casts(): array + { + return ['configuration' => 'encrypted:array', 'is_active' => 'boolean']; + } +} diff --git a/Modules/CloudDevOps/Providers/CloudDevOpsServiceProvider.php b/Modules/CloudDevOps/Providers/CloudDevOpsServiceProvider.php index 0f0dd67..763eab7 100644 --- a/Modules/CloudDevOps/Providers/CloudDevOpsServiceProvider.php +++ b/Modules/CloudDevOps/Providers/CloudDevOpsServiceProvider.php @@ -1,6 +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]); } } } + +final class CloudDevOpsServiceProvider extends ServiceProvider +{ + public function register(): void + { + $this->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/Modules/CloudDevOps/Providers/RouteServiceProvider.php b/Modules/CloudDevOps/Providers/RouteServiceProvider.php index 8c0792c..1b62500 100644 --- a/Modules/CloudDevOps/Providers/RouteServiceProvider.php +++ b/Modules/CloudDevOps/Providers/RouteServiceProvider.php @@ -1,5 +1,15 @@ group(module_path('CloudDevOps','Routes/web.php')); Route::middleware('api')->prefix('api')->group(module_path('CloudDevOps','Routes/api.php')); } } + +final class RouteServiceProvider extends ServiceProvider +{ + public function boot(): void + { + Route::middleware('web')->group(module_path('CloudDevOps', 'Routes/web.php')); + Route::middleware('api')->prefix('api')->group(module_path('CloudDevOps', 'Routes/api.php')); + } +} diff --git a/Modules/CloudDevOps/Routes/api.php b/Modules/CloudDevOps/Routes/api.php index c9a14aa..89d17bf 100644 --- a/Modules/CloudDevOps/Routes/api.php +++ b/Modules/CloudDevOps/Routes/api.php @@ -1,4 +1,9 @@ prefix('v1/cloud-devops')->group(function (): void { Route::get('/environments',[CloudDevOpsApiController::class,'environments']); Route::get('/releases',[CloudDevOpsApiController::class,'releases']); }); + +Route::middleware(['auth:sanctum', 'permission:cloud-devops.view'])->prefix('v1/cloud-devops')->group(function (): void { + Route::get('/environments', [CloudDevOpsApiController::class, 'environments']); + Route::get('/releases', [CloudDevOpsApiController::class, 'releases']); +}); diff --git a/Modules/CloudDevOps/Services/DeploymentManager.php b/Modules/CloudDevOps/Services/DeploymentManager.php index 6f12688..1b9c665 100644 --- a/Modules/CloudDevOps/Services/DeploymentManager.php +++ b/Modules/CloudDevOps/Services/DeploymentManager.php @@ -34,6 +34,7 @@ final class DeploymentManager { abort_unless(config('clouddevops.allow_execution'), 403, 'Deployment execution is disabled.'); $release->update(['status' => 'running', 'started_at' => now()]); + return $release->refresh(); } } diff --git a/Modules/CloudDevOps/Services/HealthCheckRunner.php b/Modules/CloudDevOps/Services/HealthCheckRunner.php index f967f9c..3c9f32a 100644 --- a/Modules/CloudDevOps/Services/HealthCheckRunner.php +++ b/Modules/CloudDevOps/Services/HealthCheckRunner.php @@ -1,5 +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(); } } + +final class HealthCheckRunner +{ + public function run(DeploymentHealthCheck $check): DeploymentHealthCheck + { + $started = microtime(true); + try { + $response = Http::timeout(10)->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/Modules/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php b/Modules/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php index 511ef18..5425d71 100644 --- a/Modules/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php +++ b/Modules/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php @@ -1,6 +1,18 @@ 'Production','slug'=>'production','type'=>'production']); $this->assertDatabaseHas('deployment_environments',['id'=>$environment->id,'slug'=>'production']); } } + +final class CloudDevOpsModuleTest extends TestCase +{ + use RefreshDatabase; + + public function test_environment_can_be_created(): void + { + $environment = DeploymentEnvironment::create(['name' => 'Production', 'slug' => 'production', 'type' => 'production']); + $this->assertDatabaseHas('deployment_environments', ['id' => $environment->id, 'slug' => 'production']); + } +} diff --git a/Modules/Comments/Config/config.php b/Modules/Comments/Config/config.php index f396772..e205f05 100644 --- a/Modules/Comments/Config/config.php +++ b/Modules/Comments/Config/config.php @@ -1,2 +1,3 @@ 'Comments']; diff --git a/Modules/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php b/Modules/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php index 9f27f7b..89de713 100644 --- a/Modules/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php +++ b/Modules/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('comments', function (Blueprint $table): void { + $table->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/Modules/Comments/Http/Controllers/CommentController.php b/Modules/Comments/Http/Controllers/CommentController.php index f5dc98c..c8948af 100644 --- a/Modules/Comments/Http/Controllers/CommentController.php +++ b/Modules/Comments/Http/Controllers/CommentController.php @@ -1,3 +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.'); } } + +namespace Modules\Comments\Http\Controllers; + +use App\Http\Controllers\Controller; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\View\View; +use Modules\Comments\Models\Comment; + +final class CommentController extends Controller +{ + public function index(Request $r): View + { + $q = Comment::latest(); + if ($r->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/Modules/Comments/Models/Comment.php b/Modules/Comments/Models/Comment.php index 41b6791..0da0238 100644 --- a/Modules/Comments/Models/Comment.php +++ b/Modules/Comments/Models/Comment.php @@ -1,3 +1,21 @@ 'datetime']; } public function commentable(): MorphTo { return $this->morphTo(); } } + +namespace Modules\Comments\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphTo; + +final class Comment extends Model +{ + protected $fillable = ['commentable_type', 'commentable_id', 'parent_id', 'user_id', 'author_name', 'author_email', 'body', 'status', 'ip_address', 'moderation_notes', 'approved_at']; + + protected function casts(): array + { + return ['approved_at' => 'datetime']; + } + + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/Modules/Comments/Providers/CommentsServiceProvider.php b/Modules/Comments/Providers/CommentsServiceProvider.php index d82fda6..d82e931 100644 --- a/Modules/Comments/Providers/CommentsServiceProvider.php +++ b/Modules/Comments/Providers/CommentsServiceProvider.php @@ -1,6 +1,9 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('Comments', 'Config/config.php'), 'comments'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Comments', 'Database/Migrations')); diff --git a/Modules/Comments/Providers/RouteServiceProvider.php b/Modules/Comments/Providers/RouteServiceProvider.php index 49209ce..b83240b 100644 --- a/Modules/Comments/Providers/RouteServiceProvider.php +++ b/Modules/Comments/Providers/RouteServiceProvider.php @@ -1,7 +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'); }); + +use Illuminate\Support\Facades\Route; +use Modules\Comments\Http\Controllers\CommentController; + +Route::middleware(['auth', 'verified'])->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/Modules/Comments/Tests/Feature/ModuleBootTest.php b/Modules/Comments/Tests/Feature/ModuleBootTest.php index 64fca51..bf969d3 100644 --- a/Modules/Comments/Tests/Feature/ModuleBootTest.php +++ b/Modules/Comments/Tests/Feature/ModuleBootTest.php @@ -1,6 +1,9 @@ env('COMMERCE_CURRENCY', 'GBP'), 'currency_symbol' => env('COMMERCE_CURRENCY_SYMBOL', '£'), diff --git a/Modules/Commerce/Contracts/InventoryProvider.php b/Modules/Commerce/Contracts/InventoryProvider.php index 7b21e20..d2aaa5c 100644 --- a/Modules/Commerce/Contracts/InventoryProvider.php +++ b/Modules/Commerce/Contracts/InventoryProvider.php @@ -1,4 +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(); }); + Schema::create('commerce_categories', function (Blueprint $table) { + $table->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); + 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/Modules/Commerce/Http/Controllers/CommerceController.php b/Modules/Commerce/Http/Controllers/CommerceController.php index 6bc8014..bf7eb83 100644 --- a/Modules/Commerce/Http/Controllers/CommerceController.php +++ b/Modules/Commerce/Http/Controllers/CommerceController.php @@ -1,5 +1,7 @@ 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 index(): View + { + return view('commerce::admin.index', ['products' => 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.'); + $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.'); } - 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/Modules/Commerce/Http/Controllers/ProductApiController.php b/Modules/Commerce/Http/Controllers/ProductApiController.php index e69a2ab..07e871f 100644 --- a/Modules/Commerce/Http/Controllers/ProductApiController.php +++ b/Modules/Commerce/Http/Controllers/ProductApiController.php @@ -1,10 +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()); } + public function index(): JsonResponse + { + return response()->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/Modules/Commerce/Models/Category.php b/Modules/Commerce/Models/Category.php index db747f7..76dce14 100644 --- a/Modules/Commerce/Models/Category.php +++ b/Modules/Commerce/Models/Category.php @@ -1,11 +1,17 @@ 'boolean']; } + + protected function casts(): array + { + return ['is_active' => 'boolean']; + } } diff --git a/Modules/Commerce/Models/Customer.php b/Modules/Commerce/Models/Customer.php index d02f30d..2ed400c 100644 --- a/Modules/Commerce/Models/Customer.php +++ b/Modules/Commerce/Models/Customer.php @@ -1,11 +1,17 @@ 'array','shipping_address'=>'array']; } + + protected function casts(): array + { + return ['billing_address' => 'array', 'shipping_address' => 'array']; + } } diff --git a/Modules/Commerce/Models/InventoryMovement.php b/Modules/Commerce/Models/InventoryMovement.php index 899905f..68c3cb1 100644 --- a/Modules/Commerce/Models/InventoryMovement.php +++ b/Modules/Commerce/Models/InventoryMovement.php @@ -1,11 +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']; } + + protected function casts(): array + { + return ['subtotal' => '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/Modules/Commerce/Models/OrderItem.php b/Modules/Commerce/Models/OrderItem.php index 44d6bdb..06072b7 100644 --- a/Modules/Commerce/Models/OrderItem.php +++ b/Modules/Commerce/Models/OrderItem.php @@ -1,11 +1,17 @@ 'decimal:2','tax_total'=>'decimal:2','line_total'=>'decimal:2','metadata'=>'array']; } + + protected function casts(): array + { + return ['unit_price' => 'decimal:2', 'tax_total' => 'decimal:2', 'line_total' => 'decimal:2', 'metadata' => 'array']; + } } diff --git a/Modules/Commerce/Models/Product.php b/Modules/Commerce/Models/Product.php index 77933ba..a58482f 100644 --- a/Modules/Commerce/Models/Product.php +++ b/Modules/Commerce/Models/Product.php @@ -1,11 +1,20 @@ 'decimal:2','compare_at_price'=>'decimal:2','cost_price'=>'decimal:2','track_inventory'=>'boolean','is_featured'=>'boolean','published_at'=>'datetime','metadata'=>'array']; } + + protected function casts(): array + { + return ['price' => '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/Modules/Commerce/Models/ProductVariant.php b/Modules/Commerce/Models/ProductVariant.php index ee0b549..72482ce 100644 --- a/Modules/Commerce/Models/ProductVariant.php +++ b/Modules/Commerce/Models/ProductVariant.php @@ -1,11 +1,17 @@ 'decimal:2','options'=>'array','is_active'=>'boolean']; } + + protected function casts(): array + { + return ['price' => 'decimal:2', 'options' => 'array', 'is_active' => 'boolean']; + } } diff --git a/Modules/Commerce/Models/Promotion.php b/Modules/Commerce/Models/Promotion.php index 6818b3d..e16cb92 100644 --- a/Modules/Commerce/Models/Promotion.php +++ b/Modules/Commerce/Models/Promotion.php @@ -1,11 +1,17 @@ 'decimal:2','minimum_order'=>'decimal:2','starts_at'=>'datetime','ends_at'=>'datetime','is_active'=>'boolean','conditions'=>'array']; } + + protected function casts(): array + { + return ['value' => 'decimal:2', 'minimum_order' => 'decimal:2', 'starts_at' => 'datetime', 'ends_at' => 'datetime', 'is_active' => 'boolean', 'conditions' => 'array']; + } } diff --git a/Modules/Commerce/Models/Relations.php b/Modules/Commerce/Models/Relations.php index 58836df..7814843 100644 --- a/Modules/Commerce/Models/Relations.php +++ b/Modules/Commerce/Models/Relations.php @@ -1,2 +1,3 @@ app->bind(ShippingProvider::class, FlatRateShippingProvider::class); $this->app->bind(TaxProvider::class, ConfiguredTaxProvider::class); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Commerce', 'Database/Migrations')); diff --git a/Modules/Commerce/Providers/RouteServiceProvider.php b/Modules/Commerce/Providers/RouteServiceProvider.php index 1d0c2ec..1dcfdb7 100644 --- a/Modules/Commerce/Providers/RouteServiceProvider.php +++ b/Modules/Commerce/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ 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'); + +Route::prefix('administration/commerce')->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/Modules/Commerce/Services/ConfiguredTaxProvider.php b/Modules/Commerce/Services/ConfiguredTaxProvider.php index 0952898..ee0a4ad 100644 --- a/Modules/Commerce/Services/ConfiguredTaxProvider.php +++ b/Modules/Commerce/Services/ConfiguredTaxProvider.php @@ -1,4 +1,13 @@ increment('stock_quantity',$quantity); InventoryMovement::create(['product_id'=>$product->id,'quantity'=>$quantity,'reason'=>$reason,'created_by'=>$userId]); }); + DB::transaction(function () use ($product, $quantity, $reason, $userId) { + $product->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); } - public function available(Product $product): int { return max(0,(int)$product->stock_quantity); } } diff --git a/Modules/Commerce/Services/FlatRateShippingProvider.php b/Modules/Commerce/Services/FlatRateShippingProvider.php index d7cf982..30e08ed 100644 --- a/Modules/Commerce/Services/FlatRateShippingProvider.php +++ b/Modules/Commerce/Services/FlatRateShippingProvider.php @@ -1,4 +1,13 @@ = 50 ? 0.0 : 4.95; } } + +final class FlatRateShippingProvider implements ShippingProvider +{ + public function quote(array $address, float $subtotal): float + { + return $subtotal >= 50 ? 0.0 : 4.95; + } +} diff --git a/Modules/Commerce/Services/ManualPaymentProvider.php b/Modules/Commerce/Services/ManualPaymentProvider.php index 9e99055..5d7ebed 100644 --- a/Modules/Commerce/Services/ManualPaymentProvider.php +++ b/Modules/Commerce/Services/ManualPaymentProvider.php @@ -1,6 +1,19 @@ 'pending','provider'=>'manual','reference'=>null]; } public function refund(Order $order,float $amount): array { return ['status'=>'pending','provider'=>'manual','amount'=>$amount]; } } +{ + public function charge(Order $order, array $data = []): array + { + return ['status' => 'pending', 'provider' => 'manual', 'reference' => null]; + } + + public function refund(Order $order, float $amount): array + { + return ['status' => 'pending', 'provider' => 'manual', 'amount' => $amount]; + } +} diff --git a/Modules/Commerce/Tests/Feature/ModuleBootTest.php b/Modules/Commerce/Tests/Feature/ModuleBootTest.php index 63d503e..2932611 100644 --- a/Modules/Commerce/Tests/Feature/ModuleBootTest.php +++ b/Modules/Commerce/Tests/Feature/ModuleBootTest.php @@ -1,8 +1,19 @@ assertTrue(app('router')->has('administration.commerce.index')); } - public function test_commerce_contracts_are_bound(): void { $this->assertInstanceOf(\Modules\Commerce\Contracts\InventoryProvider::class, app(\Modules\Commerce\Contracts\InventoryProvider::class)); } + public function test_commerce_routes_are_registered(): void + { + $this->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/Modules/Core/Contracts/ModuleRegistry.php b/Modules/Core/Contracts/ModuleRegistry.php index 5734b2a..e05ca63 100644 --- a/Modules/Core/Contracts/ModuleRegistry.php +++ b/Modules/Core/Contracts/ModuleRegistry.php @@ -6,5 +6,6 @@ interface ModuleRegistry { /** @return array */ public function all(): array; + public function enabled(string $name): bool; } diff --git a/Modules/Core/Providers/CoreServiceProvider.php b/Modules/Core/Providers/CoreServiceProvider.php index e785888..cee3091 100644 --- a/Modules/Core/Providers/CoreServiceProvider.php +++ b/Modules/Core/Providers/CoreServiceProvider.php @@ -3,9 +3,9 @@ namespace Modules\Core\Providers; use Illuminate\Support\ServiceProvider; -use Modules\Core\Support\ModuleInfo; use Modules\Core\Contracts\ModuleRegistry; use Modules\Core\Services\LaravelModuleRegistry; +use Modules\Core\Support\ModuleInfo; final class CoreServiceProvider extends ServiceProvider { diff --git a/Modules/Core/Routes/web.php b/Modules/Core/Routes/web.php index d6152a0..4c3fffc 100644 --- a/Modules/Core/Routes/web.php +++ b/Modules/Core/Routes/web.php @@ -1,5 +1,3 @@ $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']; + if (strcasecmp($module['name'], $name) === 0) { + return $module['enabled']; + } } + return false; } } diff --git a/Modules/Core/Support/ModuleInfo.php b/Modules/Core/Support/ModuleInfo.php index ad8de4a..5710024 100644 --- a/Modules/Core/Support/ModuleInfo.php +++ b/Modules/Core/Support/ModuleInfo.php @@ -4,6 +4,13 @@ namespace Modules\Core\Support; final class ModuleInfo { - public static function name(): string { return 'Core'; } - public static function nameLower(): string { return 'core'; } + public static function name(): string + { + return 'Core'; + } + + public static function nameLower(): string + { + return 'core'; + } } diff --git a/Modules/Developer/Console/Commands/InspectModulesCommand.php b/Modules/Developer/Console/Commands/InspectModulesCommand.php index 7d95618..11f3078 100644 --- a/Modules/Developer/Console/Commands/InspectModulesCommand.php +++ b/Modules/Developer/Console/Commands/InspectModulesCommand.php @@ -7,6 +7,7 @@ use Illuminate\Console\Command; final class InspectModulesCommand extends Command { protected $signature = 'module:inspect {name?} {--json}'; + protected $description = 'Validate module manifests and display dependencies and providers.'; public function handle(): int @@ -16,8 +17,14 @@ final class InspectModulesCommand extends Command $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; } + 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', @@ -25,8 +32,13 @@ final class InspectModulesCommand extends Command 'providers' => count($manifest['providers'] ?? []), ]; } - if ($this->option('json')) { $this->line(json_encode($rows, JSON_PRETTY_PRINT)); return self::SUCCESS; } + 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/Modules/Developer/Console/Commands/MakeCrudCommand.php b/Modules/Developer/Console/Commands/MakeCrudCommand.php index 8c83989..d615a48 100644 --- a/Modules/Developer/Console/Commands/MakeCrudCommand.php +++ b/Modules/Developer/Console/Commands/MakeCrudCommand.php @@ -9,15 +9,24 @@ use Throwable; final class MakeCrudCommand extends Command { protected $signature = 'crud:make {model} {--module=} {--force}'; + protected $description = 'Generate model, migration, request, controller, views and test inside a module.'; public function handle(CrudGenerator $generator): int { $module = $this->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}"); } + 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; } diff --git a/Modules/Developer/Console/Commands/MakeModuleCommand.php b/Modules/Developer/Console/Commands/MakeModuleCommand.php index 53e48d9..34ece37 100644 --- a/Modules/Developer/Console/Commands/MakeModuleCommand.php +++ b/Modules/Developer/Console/Commands/MakeModuleCommand.php @@ -9,14 +9,24 @@ use Throwable; final class MakeModuleCommand extends Command { protected $signature = 'module:make {name} {--force}'; + protected $description = 'Generate a complete modular Laravel module scaffold.'; public function handle(ModuleGenerator $generator): int { - try { $files = $generator->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.'); + try { + $files = $generator->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/Modules/Developer/Console/Commands/MakeThemeCommand.php b/Modules/Developer/Console/Commands/MakeThemeCommand.php index 50c803b..2457559 100644 --- a/Modules/Developer/Console/Commands/MakeThemeCommand.php +++ b/Modules/Developer/Console/Commands/MakeThemeCommand.php @@ -9,13 +9,22 @@ use Throwable; final class MakeThemeCommand extends Command { protected $signature = 'theme:make {name} {--force}'; + protected $description = 'Generate a theme scaffold with metadata, layout and assets.'; public function handle(ThemeGenerator $generator): int { - try { $files = $generator->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}"); } + try { + $files = $generator->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/Modules/Developer/Support/CrudGenerator.php b/Modules/Developer/Support/CrudGenerator.php index c1b7c07..90db77c 100644 --- a/Modules/Developer/Support/CrudGenerator.php +++ b/Modules/Developer/Support/CrudGenerator.php @@ -38,6 +38,7 @@ final class CrudGenerator extends Generator 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"; } diff --git a/Modules/Developer/Support/ThemeGenerator.php b/Modules/Developer/Support/ThemeGenerator.php index e1ed4b7..dd1de18 100644 --- a/Modules/Developer/Support/ThemeGenerator.php +++ b/Modules/Developer/Support/ThemeGenerator.php @@ -13,9 +13,12 @@ final class ThemeGenerator extends Generator "$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", + "$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); } + foreach ($files as $path => $contents) { + $this->write($path, $contents, $force); + } + return array_keys($files); } } diff --git a/Modules/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php b/Modules/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php index 60bc66e..87aaf16 100644 --- a/Modules/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php +++ b/Modules/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php @@ -4,12 +4,23 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { - public function up(): void { Schema::create('documents', function (Blueprint $table): void { $table->id(); +return new class extends Migration +{ + public function up(): void + { + Schema::create('documents', function (Blueprint $table): void { + $table->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'); } + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('documents'); + } }; diff --git a/Modules/Documents/Http/Controllers/DocumentController.php b/Modules/Documents/Http/Controllers/DocumentController.php index e14d691..d4701d0 100644 --- a/Modules/Documents/Http/Controllers/DocumentController.php +++ b/Modules/Documents/Http/Controllers/DocumentController.php @@ -10,16 +10,49 @@ use Modules\Documents\Models\Document; final class DocumentController extends Controller { - public function index(): View { return view('documents::admin.index', ['records' => 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'], + public function index(): View + { + return view('documents::admin.index', ['records' => 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('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'], + 'is_public' => ['sometimes', 'boolean']]); + $data['is_public'] = $request->boolean('is_public'); + Document::create($data); + + return redirect()->route('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('documents.index')->with('status', 'Document updated.'); } - public function destroy(Document $document): RedirectResponse { $document->delete(); return back()->with('status', 'Document deleted.'); } + 'is_public' => ['sometimes', 'boolean']]); + $data['is_public'] = $request->boolean('is_public'); + $document->update($data); + + return redirect()->route('documents.index')->with('status', 'Document updated.'); + } + + public function destroy(Document $document): RedirectResponse + { + $document->delete(); + + return back()->with('status', 'Document deleted.'); + } } diff --git a/Modules/Documents/Models/Document.php b/Modules/Documents/Models/Document.php index 8009f45..2d9d2cf 100644 --- a/Modules/Documents/Models/Document.php +++ b/Modules/Documents/Models/Document.php @@ -9,11 +9,15 @@ use Illuminate\Database\Eloquent\SoftDeletes; final class Document extends Model { use HasFactory, SoftDeletes; - protected $table = 'documents'; - protected $fillable = ['title', 'description', 'file_path', 'is_public']; - - protected function casts(): array { return [ - 'is_public' => 'boolean' - ]; } + protected $table = 'documents'; + + protected $fillable = ['title', 'description', 'file_path', 'is_public']; + + protected function casts(): array + { + return [ + 'is_public' => 'boolean', + ]; + } } diff --git a/Modules/Documents/Support/ModuleInfo.php b/Modules/Documents/Support/ModuleInfo.php index 2643ae5..c815ec2 100644 --- a/Modules/Documents/Support/ModuleInfo.php +++ b/Modules/Documents/Support/ModuleInfo.php @@ -2,4 +2,15 @@ namespace Modules\Documents\Support; -final class ModuleInfo { public static function name(): string { return 'Documents'; } public static function nameLower(): string { return 'documents'; } } +final class ModuleInfo +{ + public static function name(): string + { + return 'Documents'; + } + + public static function nameLower(): string + { + return 'documents'; + } +} diff --git a/Modules/Documents/Tests/Feature/ModuleBootTest.php b/Modules/Documents/Tests/Feature/ModuleBootTest.php index d1dc93f..7a0edd4 100644 --- a/Modules/Documents/Tests/Feature/ModuleBootTest.php +++ b/Modules/Documents/Tests/Feature/ModuleBootTest.php @@ -2,4 +2,6 @@ use Illuminate\Support\Facades\Route; -it('registers the Documents administration route', function (): void { expect(Route::has('administration.documents.index'))->toBeTrue(); }); +it('registers the Documents administration route', function (): void { + expect(Route::has('administration.documents.index'))->toBeTrue(); +}); diff --git a/Modules/Enterprise/Config/config.php b/Modules/Enterprise/Config/config.php index 12b78c1..2dd4c3a 100644 --- a/Modules/Enterprise/Config/config.php +++ b/Modules/Enterprise/Config/config.php @@ -1,4 +1,5 @@ env('ENTERPRISE_BACKUP_DISK', 'local'), 'backup_path' => env('ENTERPRISE_BACKUP_PATH', 'backups'), diff --git a/Modules/Enterprise/Console/BackupCreateCommand.php b/Modules/Enterprise/Console/BackupCreateCommand.php index feeebc9..3492f0c 100644 --- a/Modules/Enterprise/Console/BackupCreateCommand.php +++ b/Modules/Enterprise/Console/BackupCreateCommand.php @@ -1,4 +1,5 @@ create(); $this->info('Backup created: '.$backup->path); return self::SUCCESS; } + + public function handle(BackupService $service): int + { + $backup = $service->create(); + $this->info('Backup created: '.$backup->path); + + return self::SUCCESS; + } } diff --git a/Modules/Enterprise/Console/BackupPruneCommand.php b/Modules/Enterprise/Console/BackupPruneCommand.php index c77a56c..bd7fbf4 100644 --- a/Modules/Enterprise/Console/BackupPruneCommand.php +++ b/Modules/Enterprise/Console/BackupPruneCommand.php @@ -1,4 +1,5 @@ info($service->prune().' backup(s) pruned.'); return self::SUCCESS; } + + public function handle(BackupService $service): int + { + $this->info($service->prune().' backup(s) pruned.'); + + return self::SUCCESS; + } } diff --git a/Modules/Enterprise/Console/HealthCheckCommand.php b/Modules/Enterprise/Console/HealthCheckCommand.php index 9961fd5..40d1b07 100644 --- a/Modules/Enterprise/Console/HealthCheckCommand.php +++ b/Modules/Enterprise/Console/HealthCheckCommand.php @@ -1,4 +1,5 @@ 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 { + 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/Modules/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php b/Modules/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php index 12c8f3f..1413a3f 100644 --- a/Modules/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php +++ b/Modules/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php @@ -1,9 +1,11 @@ run(); HealthRun::create(['user_id' => auth()->id(), 'status' => $result['status'], 'results' => $result, 'ran_at' => now()]); + return back()->with('status', 'Health checks completed.'); } @@ -40,18 +42,21 @@ final class EnterpriseController { 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.'); } @@ -59,9 +64,11 @@ final class EnterpriseController { try { $service->create(auth()->id()); + return back()->with('status', 'Backup created.'); } catch (Throwable $e) { report($e); + return back()->withErrors(['backup' => $e->getMessage()]); } } @@ -69,6 +76,7 @@ final class EnterpriseController public function deleteBackup(Backup $backup, BackupService $service): RedirectResponse { $service->delete($backup); + return back()->with('status', 'Backup deleted.'); } } diff --git a/Modules/Enterprise/Models/Backup.php b/Modules/Enterprise/Models/Backup.php index 3d5ba6e..14ecef9 100644 --- a/Modules/Enterprise/Models/Backup.php +++ b/Modules/Enterprise/Models/Backup.php @@ -1,4 +1,5 @@ 'datetime']; } - public function user(): BelongsTo { return $this->belongsTo(User::class); } + + protected function casts(): array + { + return ['completed_at' => 'datetime']; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } } diff --git a/Modules/Enterprise/Models/HealthRun.php b/Modules/Enterprise/Models/HealthRun.php index eba66b0..34824c2 100644 --- a/Modules/Enterprise/Models/HealthRun.php +++ b/Modules/Enterprise/Models/HealthRun.php @@ -1,4 +1,5 @@ 'array', 'ran_at' => 'datetime']; } - public function user(): BelongsTo { return $this->belongsTo(User::class); } + + protected function casts(): array + { + return ['results' => 'array', 'ran_at' => 'datetime']; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } } diff --git a/Modules/Enterprise/Providers/EnterpriseServiceProvider.php b/Modules/Enterprise/Providers/EnterpriseServiceProvider.php index 59418dc..a532d0a 100644 --- a/Modules/Enterprise/Providers/EnterpriseServiceProvider.php +++ b/Modules/Enterprise/Providers/EnterpriseServiceProvider.php @@ -1,4 +1,5 @@ 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.'); + $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'), @@ -30,10 +35,14 @@ final class BackupService ], 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 (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'); + if (File::exists($database)) { + $zip->addFile($database, 'database/database.sqlite'); + } } $zip->close(); @@ -61,6 +70,7 @@ final class BackupService $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/Modules/Enterprise/Services/SystemHealthService.php b/Modules/Enterprise/Services/SystemHealthService.php index 01d1348..b08b319 100644 --- a/Modules/Enterprise/Services/SystemHealthService.php +++ b/Modules/Enterprise/Services/SystemHealthService.php @@ -1,4 +1,5 @@ getPdo(); + return $this->result('Database', 'healthy', DB::connection()->getDriverName()); } catch (Throwable $e) { return $this->result('Database', 'failed', $e->getMessage()); @@ -43,6 +45,7 @@ final class SystemHealthService 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()); @@ -56,6 +59,7 @@ final class SystemHealthService 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()); @@ -66,14 +70,20 @@ final class SystemHealthService { $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'; + 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()); } diff --git a/Modules/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php b/Modules/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php index 1715ea5..fe1d1d1 100644 --- a/Modules/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php +++ b/Modules/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php @@ -1,7 +1,9 @@ expect(app()->providerIsLoaded(Modules\Enterprise\Providers\EnterpriseServiceProvider::class))->toBeTrue()); +it('boots the enterprise module', fn () => 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/Modules/Events/Config/config.php b/Modules/Events/Config/config.php index 13c1a29..6e87093 100644 --- a/Modules/Events/Config/config.php +++ b/Modules/Events/Config/config.php @@ -1,2 +1,3 @@ 'Events']; diff --git a/Modules/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php b/Modules/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php index 5368f69..a974f12 100644 --- a/Modules/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php +++ b/Modules/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('event_venues', function (Blueprint $t): void { + $t->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/Modules/Events/Http/Controllers/EventController.php b/Modules/Events/Http/Controllers/EventController.php index ef50ff9..5668979 100644 --- a/Modules/Events/Http/Controllers/EventController.php +++ b/Modules/Events/Http/Controllers/EventController.php @@ -1,3 +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.'); } } + +namespace Modules\Events\Http\Controllers; + +use App\Http\Controllers\Controller; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\View\View; +use Modules\Events\Models\Event; +use Modules\Events\Models\Venue; + +final class EventController extends Controller +{ + public function index(): View + { + return view('events::admin.index', ['events' => 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/Modules/Events/Models/Event.php b/Modules/Events/Models/Event.php index a96b5f4..cb1d6d4 100644 --- a/Modules/Events/Models/Event.php +++ b/Modules/Events/Models/Event.php @@ -1,3 +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); } } + +namespace Modules\Events\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; + +final class Event extends Model +{ + protected $fillable = ['venue_id', 'title', 'slug', 'summary', 'description', 'starts_at', 'ends_at', 'status', 'capacity', 'price', 'registration_open']; + + protected function casts(): array + { + return ['starts_at' => '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/Modules/Events/Models/Registration.php b/Modules/Events/Models/Registration.php index ac071c9..1ea8d7f 100644 --- a/Modules/Events/Models/Registration.php +++ b/Modules/Events/Models/Registration.php @@ -1,3 +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')); diff --git a/Modules/Events/Providers/RouteServiceProvider.php b/Modules/Events/Providers/RouteServiceProvider.php index 136f133..ca21554 100644 --- a/Modules/Events/Providers/RouteServiceProvider.php +++ b/Modules/Events/Providers/RouteServiceProvider.php @@ -1,7 +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'); }); + +use Illuminate\Support\Facades\Route; +use Modules\Events\Http\Controllers\EventController; + +Route::middleware(['auth', 'verified'])->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/Modules/Events/Tests/Feature/ModuleBootTest.php b/Modules/Events/Tests/Feature/ModuleBootTest.php index 0c262ac..27cd6b4 100644 --- a/Modules/Events/Tests/Feature/ModuleBootTest.php +++ b/Modules/Events/Tests/Feature/ModuleBootTest.php @@ -1,6 +1,9 @@ id(); +return new class extends Migration +{ + public function up(): void + { + Schema::create('faqs', function (Blueprint $table): void { + $table->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'); } + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('faqs'); + } }; diff --git a/Modules/Faq/Http/Controllers/FaqController.php b/Modules/Faq/Http/Controllers/FaqController.php index e58c082..03d09ba 100644 --- a/Modules/Faq/Http/Controllers/FaqController.php +++ b/Modules/Faq/Http/Controllers/FaqController.php @@ -10,18 +10,51 @@ use Modules\Faq\Models\Faq; final class FaqController extends Controller { - public function index(): View { return view('faq::admin.index', ['records' => 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'], + public function index(): View + { + return view('faq::admin.index', ['records' => 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('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'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + Faq::create($data); + + return redirect()->route('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('faq.index')->with('status', 'Faq updated.'); } - public function destroy(Faq $faq): RedirectResponse { $faq->delete(); return back()->with('status', 'Faq deleted.'); } + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + $faq->update($data); + + return redirect()->route('faq.index')->with('status', 'Faq updated.'); + } + + public function destroy(Faq $faq): RedirectResponse + { + $faq->delete(); + + return back()->with('status', 'Faq deleted.'); + } } diff --git a/Modules/Faq/Models/Faq.php b/Modules/Faq/Models/Faq.php index b381686..b2011f3 100644 --- a/Modules/Faq/Models/Faq.php +++ b/Modules/Faq/Models/Faq.php @@ -9,12 +9,16 @@ use Illuminate\Database\Eloquent\SoftDeletes; final class Faq extends Model { use HasFactory, SoftDeletes; - protected $table = 'faqs'; - protected $fillable = ['question', 'answer', 'category', 'sort_order', 'enabled']; - - protected function casts(): array { return [ - 'sort_order' => 'integer', - 'enabled' => 'boolean' - ]; } + protected $table = 'faqs'; + + protected $fillable = ['question', 'answer', 'category', 'sort_order', 'enabled']; + + protected function casts(): array + { + return [ + 'sort_order' => 'integer', + 'enabled' => 'boolean', + ]; + } } diff --git a/Modules/Faq/Support/ModuleInfo.php b/Modules/Faq/Support/ModuleInfo.php index 0b812e8..34b932c 100644 --- a/Modules/Faq/Support/ModuleInfo.php +++ b/Modules/Faq/Support/ModuleInfo.php @@ -2,4 +2,15 @@ namespace Modules\Faq\Support; -final class ModuleInfo { public static function name(): string { return 'Faq'; } public static function nameLower(): string { return 'faq'; } } +final class ModuleInfo +{ + public static function name(): string + { + return 'Faq'; + } + + public static function nameLower(): string + { + return 'faq'; + } +} diff --git a/Modules/Faq/Tests/Feature/ModuleBootTest.php b/Modules/Faq/Tests/Feature/ModuleBootTest.php index 6de28c6..99b4bfd 100644 --- a/Modules/Faq/Tests/Feature/ModuleBootTest.php +++ b/Modules/Faq/Tests/Feature/ModuleBootTest.php @@ -2,4 +2,6 @@ use Illuminate\Support\Facades\Route; -it('registers the Faq administration route', function (): void { expect(Route::has('administration.faq.index'))->toBeTrue(); }); +it('registers the Faq administration route', function (): void { + expect(Route::has('administration.faq.index'))->toBeTrue(); +}); diff --git a/Modules/Feeds/Config/config.php b/Modules/Feeds/Config/config.php index 0a91fe1..166f718 100644 --- a/Modules/Feeds/Config/config.php +++ b/Modules/Feeds/Config/config.php @@ -1,2 +1,3 @@ 'Feeds']; diff --git a/Modules/Feeds/Http/Controllers/FeedController.php b/Modules/Feeds/Http/Controllers/FeedController.php index fae5be4..8f2fab8 100644 --- a/Modules/Feeds/Http/Controllers/FeedController.php +++ b/Modules/Feeds/Http/Controllers/FeedController.php @@ -1,3 +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'); } } + +namespace Modules\Feeds\Http\Controllers; + +use App\Http\Controllers\Controller; +use Illuminate\Http\Response; +use Modules\Blog\Models\Blog; +use Modules\News\Models\News; + +final class FeedController extends Controller +{ + public function blog(): Response + { + return response()->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/Modules/Feeds/Providers/FeedsServiceProvider.php b/Modules/Feeds/Providers/FeedsServiceProvider.php index 324230b..c1200c7 100644 --- a/Modules/Feeds/Providers/FeedsServiceProvider.php +++ b/Modules/Feeds/Providers/FeedsServiceProvider.php @@ -1,6 +1,9 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('Feeds', 'Config/config.php'), 'feeds'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Feeds', 'Database/Migrations')); diff --git a/Modules/Feeds/Providers/RouteServiceProvider.php b/Modules/Feeds/Providers/RouteServiceProvider.php index 443ab7f..89c193a 100644 --- a/Modules/Feeds/Providers/RouteServiceProvider.php +++ b/Modules/Feeds/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ name('feeds.blog'); Route::get('/feeds/news.xml',[FeedController::class,'news'])->name('feeds.news'); + +use Illuminate\Support\Facades\Route; +use Modules\Feeds\Http\Controllers\FeedController; + +Route::get('/feeds/blog.xml', [FeedController::class, 'blog'])->name('feeds.blog'); +Route::get('/feeds/news.xml', [FeedController::class, 'news'])->name('feeds.news'); diff --git a/Modules/Feeds/Tests/Feature/ModuleBootTest.php b/Modules/Feeds/Tests/Feature/ModuleBootTest.php index 8e40d43..7b90716 100644 --- a/Modules/Feeds/Tests/Feature/ModuleBootTest.php +++ b/Modules/Feeds/Tests/Feature/ModuleBootTest.php @@ -1,6 +1,9 @@ id(); +return new class extends Migration +{ + public function up(): void + { + Schema::create('forms', function (Blueprint $table): void { + $table->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'); } + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('forms'); + } }; diff --git a/Modules/Forms/Http/Controllers/FormController.php b/Modules/Forms/Http/Controllers/FormController.php index 2ba15ed..395ccf6 100644 --- a/Modules/Forms/Http/Controllers/FormController.php +++ b/Modules/Forms/Http/Controllers/FormController.php @@ -10,20 +10,57 @@ use Modules\Forms\Models\Form; final class FormController extends Controller { - public function index(): View { return view('forms::admin.index', ['records' => 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'], + public function index(): View + { + return view('forms::admin.index', ['records' => 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('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'], + '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('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('forms.index')->with('status', 'Form updated.'); } - public function destroy(Form $form): RedirectResponse { $form->delete(); return back()->with('status', 'Form deleted.'); } + '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('forms.index')->with('status', 'Form updated.'); + } + + public function destroy(Form $form): RedirectResponse + { + $form->delete(); + + return back()->with('status', 'Form deleted.'); + } } diff --git a/Modules/Forms/Models/Form.php b/Modules/Forms/Models/Form.php index 2d8c1e4..d72aec6 100644 --- a/Modules/Forms/Models/Form.php +++ b/Modules/Forms/Models/Form.php @@ -9,12 +9,16 @@ use Illuminate\Database\Eloquent\SoftDeletes; final class Form extends Model { use HasFactory, SoftDeletes; - protected $table = 'forms'; - protected $fillable = ['name', 'slug', 'description', 'schema', 'enabled']; - - protected function casts(): array { return [ - 'schema' => 'array', - 'enabled' => 'boolean' - ]; } + protected $table = 'forms'; + + protected $fillable = ['name', 'slug', 'description', 'schema', 'enabled']; + + protected function casts(): array + { + return [ + 'schema' => 'array', + 'enabled' => 'boolean', + ]; + } } diff --git a/Modules/Forms/Support/ModuleInfo.php b/Modules/Forms/Support/ModuleInfo.php index a6e2507..916091c 100644 --- a/Modules/Forms/Support/ModuleInfo.php +++ b/Modules/Forms/Support/ModuleInfo.php @@ -2,4 +2,15 @@ namespace Modules\Forms\Support; -final class ModuleInfo { public static function name(): string { return 'Forms'; } public static function nameLower(): string { return 'forms'; } } +final class ModuleInfo +{ + public static function name(): string + { + return 'Forms'; + } + + public static function nameLower(): string + { + return 'forms'; + } +} diff --git a/Modules/Forms/Tests/Feature/ModuleBootTest.php b/Modules/Forms/Tests/Feature/ModuleBootTest.php index 7201fc0..fc85e0e 100644 --- a/Modules/Forms/Tests/Feature/ModuleBootTest.php +++ b/Modules/Forms/Tests/Feature/ModuleBootTest.php @@ -2,4 +2,6 @@ use Illuminate\Support\Facades\Route; -it('registers the Forms administration route', function (): void { expect(Route::has('administration.forms.index'))->toBeTrue(); }); +it('registers the Forms administration route', function (): void { + expect(Route::has('administration.forms.index'))->toBeTrue(); +}); diff --git a/Modules/Localization/Concerns/HasTranslations.php b/Modules/Localization/Concerns/HasTranslations.php index 993dea5..4a09f34 100644 --- a/Modules/Localization/Concerns/HasTranslations.php +++ b/Modules/Localization/Concerns/HasTranslations.php @@ -1,24 +1,33 @@ 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; } + 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/Modules/Localization/Config/config.php b/Modules/Localization/Config/config.php index e5f5736..da2392d 100644 --- a/Modules/Localization/Config/config.php +++ b/Modules/Localization/Config/config.php @@ -1,11 +1,16 @@ env('APP_LOCALE', 'en'), 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), 'session_key' => 'platform_locale', 'supported_subjects' => [ - 'pages' => ['model' => Modules\Pages\Models\Page::class, 'label' => 'Pages', 'fields' => ['title', 'summary', 'content', 'meta_title', 'meta_description']], - 'blog' => ['model' => Modules\Blog\Models\Blog::class, 'label' => 'Blog', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']], - 'news' => ['model' => Modules\News\Models\News::class, 'label' => 'News', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']], + '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/Modules/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php b/Modules/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php index 592570a..24f8aba 100644 --- a/Modules/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php +++ b/Modules/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php @@ -1,7 +1,9 @@ index(['locale_id', 'field']); }); } + public function down(): void { Schema::dropIfExists('localized_contents'); diff --git a/Modules/Localization/Database/Seeders/LocaleSeeder.php b/Modules/Localization/Database/Seeders/LocaleSeeder.php index 1570ff8..1bf793d 100644 --- a/Modules/Localization/Database/Seeders/LocaleSeeder.php +++ b/Modules/Localization/Database/Seeders/LocaleSeeder.php @@ -1,7 +1,10 @@ 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]); } + 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]); } + 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'], + '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/Modules/Localization/Http/Controllers/TranslationController.php b/Modules/Localization/Http/Controllers/TranslationController.php index 1c27df3..69fd8b5 100644 --- a/Modules/Localization/Http/Controllers/TranslationController.php +++ b/Modules/Localization/Http/Controllers/TranslationController.php @@ -1,10 +1,13 @@ 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); @@ -24,13 +29,16 @@ final class TranslationController extends Controller $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/Modules/Localization/Models/Locale.php b/Modules/Localization/Models/Locale.php index f204c9d..97fa973 100644 --- a/Modules/Localization/Models/Locale.php +++ b/Modules/Localization/Models/Locale.php @@ -1,12 +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'); } + protected $fillable = ['code', 'name', 'native_name', 'regional_code', 'direction', 'date_format', 'time_format', 'timezone', 'currency_code', 'currency_symbol', 'position', 'is_default', 'is_active']; + + protected function casts(): array + { + return ['is_default' => '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/Modules/Localization/Models/LocalizedContent.php b/Modules/Localization/Models/LocalizedContent.php index 4c8a489..2a10675 100644 --- a/Modules/Localization/Models/LocalizedContent.php +++ b/Modules/Localization/Models/LocalizedContent.php @@ -1,13 +1,28 @@ morphTo(); } - public function locale(): BelongsTo { return $this->belongsTo(Locale::class); } - public function editor(): BelongsTo { return $this->belongsTo(User::class, 'updated_by'); } + protected $fillable = ['locale_id', 'field', 'value', 'updated_by']; + + public function translatable(): MorphTo + { + return $this->morphTo(); + } + + public function locale(): BelongsTo + { + return $this->belongsTo(Locale::class); + } + + public function editor(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } } diff --git a/Modules/Localization/Providers/LocalizationServiceProvider.php b/Modules/Localization/Providers/LocalizationServiceProvider.php index 8e2e6b5..6383fa8 100644 --- a/Modules/Localization/Providers/LocalizationServiceProvider.php +++ b/Modules/Localization/Providers/LocalizationServiceProvider.php @@ -1,7 +1,10 @@ app->singleton(LocaleManager::class); $this->app->register(RouteServiceProvider::class); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Localization', 'Database/Migrations')); diff --git a/Modules/Localization/Providers/RouteServiceProvider.php b/Modules/Localization/Providers/RouteServiceProvider.php index 4856455..39dae19 100644 --- a/Modules/Localization/Providers/RouteServiceProvider.php +++ b/Modules/Localization/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ name('localization.switch'); -Route::middleware(['auth','verified'])->prefix('administration/localization')->name('administration.localization.')->group(function (): void { +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'); diff --git a/Modules/Localization/Services/LocaleManager.php b/Modules/Localization/Services/LocaleManager.php index ffde98b..0454a58 100644 --- a/Modules/Localization/Services/LocaleManager.php +++ b/Modules/Localization/Services/LocaleManager.php @@ -1,16 +1,28 @@ active()->get(); } - public function default(): ?Locale { return Locale::query()->where('is_default', true)->first() ?? Locale::query()->active()->first(); } + public function active(): Collection + { + return Locale::query()->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/Modules/Localization/Tests/Feature/ModuleBootTest.php b/Modules/Localization/Tests/Feature/ModuleBootTest.php index a1e77c9..c4aea49 100644 --- a/Modules/Localization/Tests/Feature/ModuleBootTest.php +++ b/Modules/Localization/Tests/Feature/ModuleBootTest.php @@ -1,10 +1,14 @@ assertTrue(app()->providerIsLoaded(\Modules\Localization\Providers\LocalizationServiceProvider::class)); + $this->assertTrue(app()->providerIsLoaded(LocalizationServiceProvider::class)); } } diff --git a/Modules/Locations/Config/config.php b/Modules/Locations/Config/config.php index 6269238..ba035af 100644 --- a/Modules/Locations/Config/config.php +++ b/Modules/Locations/Config/config.php @@ -1,2 +1,3 @@ 'Locations']; diff --git a/Modules/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php b/Modules/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php index 13f2c46..8ef54fb 100644 --- a/Modules/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php +++ b/Modules/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('locations', function (Blueprint $t): void { + $t->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/Modules/Locations/Http/Controllers/LocationController.php b/Modules/Locations/Http/Controllers/LocationController.php index cb6dce7..a1b2606 100644 --- a/Modules/Locations/Http/Controllers/LocationController.php +++ b/Modules/Locations/Http/Controllers/LocationController.php @@ -1,3 +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.'); } } + +namespace Modules\Locations\Http\Controllers; + +use App\Http\Controllers\Controller; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\View\View; +use Modules\Locations\Models\Location; + +final class LocationController extends Controller +{ + public function index(): View + { + return view('locations::admin.index', ['locations' => 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/Modules/Locations/Models/Location.php b/Modules/Locations/Models/Location.php index 8ac3d1d..913fd40 100644 --- a/Modules/Locations/Models/Location.php +++ b/Modules/Locations/Models/Location.php @@ -1,3 +1,15 @@ 'decimal:7','longitude'=>'decimal:7','opening_hours'=>'array','is_active'=>'boolean']; } } + +namespace Modules\Locations\Models; + +use Illuminate\Database\Eloquent\Model; + +final class Location extends Model +{ + protected $fillable = ['name', 'slug', 'type', 'address', 'postcode', 'country', 'phone', 'email', 'latitude', 'longitude', 'opening_hours', 'is_active', 'sort_order']; + + protected function casts(): array + { + return ['latitude' => 'decimal:7', 'longitude' => 'decimal:7', 'opening_hours' => 'array', 'is_active' => 'boolean']; + } +} diff --git a/Modules/Locations/Providers/LocationsServiceProvider.php b/Modules/Locations/Providers/LocationsServiceProvider.php index a13736e..de2419b 100644 --- a/Modules/Locations/Providers/LocationsServiceProvider.php +++ b/Modules/Locations/Providers/LocationsServiceProvider.php @@ -1,6 +1,9 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('Locations', 'Config/config.php'), 'locations'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Locations', 'Database/Migrations')); diff --git a/Modules/Locations/Providers/RouteServiceProvider.php b/Modules/Locations/Providers/RouteServiceProvider.php index ceb62de..cb2ee3e 100644 --- a/Modules/Locations/Providers/RouteServiceProvider.php +++ b/Modules/Locations/Providers/RouteServiceProvider.php @@ -1,7 +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'); }); + +use Illuminate\Support\Facades\Route; +use Modules\Locations\Http\Controllers\LocationController; + +Route::middleware(['auth', 'verified'])->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/Modules/Locations/Tests/Feature/ModuleBootTest.php b/Modules/Locations/Tests/Feature/ModuleBootTest.php index 1ae9804..c8dec12 100644 --- a/Modules/Locations/Tests/Feature/ModuleBootTest.php +++ b/Modules/Locations/Tests/Feature/ModuleBootTest.php @@ -1,6 +1,9 @@ (bool) env('MARKETPLACE_ENABLED', true), 'catalogue_url' => env('MARKETPLACE_CATALOGUE_URL'), diff --git a/Modules/Marketplace/Console/Commands/MarketplaceAuditCommand.php b/Modules/Marketplace/Console/Commands/MarketplaceAuditCommand.php index f8d4a4f..b8a36b0 100644 --- a/Modules/Marketplace/Console/Commands/MarketplaceAuditCommand.php +++ b/Modules/Marketplace/Console/Commands/MarketplaceAuditCommand.php @@ -1,14 +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; + $packages = InstalledPackage::query()->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/Modules/Marketplace/Console/Commands/MarketplaceSyncCommand.php b/Modules/Marketplace/Console/Commands/MarketplaceSyncCommand.php index 9625253..060aa58 100644 --- a/Modules/Marketplace/Console/Commands/MarketplaceSyncCommand.php +++ b/Modules/Marketplace/Console/Commands/MarketplaceSyncCommand.php @@ -1,12 +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; + if (blank(config('marketplace.catalogue_url'))) { + $this->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/Modules/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php b/Modules/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php index 622087d..eb2a3b9 100644 --- a/Modules/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php +++ b/Modules/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php @@ -1,48 +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(); + $table->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(); + $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(); + $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(); + $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(); + $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'); + Schema::dropIfExists('marketplace_licences'); + Schema::dropIfExists('marketplace_installations'); + Schema::dropIfExists('marketplace_installed_packages'); + Schema::dropIfExists('marketplace_packages'); + Schema::dropIfExists('marketplace_publishers'); } }; diff --git a/Modules/Marketplace/Http/Controllers/MarketplaceApiController.php b/Modules/Marketplace/Http/Controllers/MarketplaceApiController.php index b4d2499..958d8f3 100644 --- a/Modules/Marketplace/Http/Controllers/MarketplaceApiController.php +++ b/Modules/Marketplace/Http/Controllers/MarketplaceApiController.php @@ -1,16 +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']); } + public function packages(Request $request): JsonResponse + { + return response()->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/Modules/Marketplace/Http/Controllers/MarketplaceController.php b/Modules/Marketplace/Http/Controllers/MarketplaceController.php index 7eca206..45744a0 100644 --- a/Modules/Marketplace/Http/Controllers/MarketplaceController.php +++ b/Modules/Marketplace/Http/Controllers/MarketplaceController.php @@ -1,5 +1,7 @@ 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')); + $packages = MarketplacePackage::query()->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.'); } - 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/Modules/Marketplace/Models/Installation.php b/Modules/Marketplace/Models/Installation.php index 6573279..c152b8c 100644 --- a/Modules/Marketplace/Models/Installation.php +++ b/Modules/Marketplace/Models/Installation.php @@ -1,9 +1,14 @@ 'array','started_at'=>'datetime','completed_at'=>'datetime']; + protected $table = 'marketplace_installations'; + + protected $fillable = ['installed_package_id', 'package_name', 'operation', 'from_version', 'to_version', 'status', 'snapshot_path', 'message', 'context', 'performed_by', 'started_at', 'completed_at']; + + protected $casts = ['context' => 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime']; } diff --git a/Modules/Marketplace/Models/InstalledPackage.php b/Modules/Marketplace/Models/InstalledPackage.php index c2dace8..bb7eda5 100644 --- a/Modules/Marketplace/Models/InstalledPackage.php +++ b/Modules/Marketplace/Models/InstalledPackage.php @@ -1,14 +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,'>'); } + protected $table = 'marketplace_installed_packages'; + + protected $fillable = ['marketplace_package_id', 'package_name', 'type', 'version', 'status', 'manifest', 'checksum', 'install_path', 'is_enabled', 'installed_by', 'installed_at', 'updated_at_marketplace']; + + protected $casts = ['manifest' => '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/Modules/Marketplace/Models/MarketplaceLicence.php b/Modules/Marketplace/Models/MarketplaceLicence.php index 48ef02a..22038e8 100644 --- a/Modules/Marketplace/Models/MarketplaceLicence.php +++ b/Modules/Marketplace/Models/MarketplaceLicence.php @@ -1,10 +1,16 @@ 'datetime','expires_at'=>'datetime','metadata'=>'array']; + protected $table = 'marketplace_licences'; + + protected $fillable = ['marketplace_package_id', 'licence_key', 'status', 'plan', 'licensed_to', 'activated_at', 'expires_at', 'metadata']; + + protected $hidden = ['licence_key']; + + protected $casts = ['activated_at' => 'datetime', 'expires_at' => 'datetime', 'metadata' => 'array']; } diff --git a/Modules/Marketplace/Models/MarketplacePackage.php b/Modules/Marketplace/Models/MarketplacePackage.php index 7134aa6..ad4a0c2 100644 --- a/Modules/Marketplace/Models/MarketplacePackage.php +++ b/Modules/Marketplace/Models/MarketplacePackage.php @@ -1,13 +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'); } + protected $table = 'marketplace_packages'; + + protected $fillable = ['publisher_id', 'type', 'name', 'slug', 'package_name', 'description', 'latest_version', 'homepage', 'repository_url', 'download_url', 'checksum', 'signature', 'manifest', 'is_featured', 'is_active', 'catalogue_updated_at']; + + protected $casts = ['manifest' => '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/Modules/Marketplace/Models/Publisher.php b/Modules/Marketplace/Models/Publisher.php index b74acba..16558ef 100644 --- a/Modules/Marketplace/Models/Publisher.php +++ b/Modules/Marketplace/Models/Publisher.php @@ -1,11 +1,20 @@ 'boolean','metadata'=>'array']; - public function packages(): HasMany { return $this->hasMany(MarketplacePackage::class,'publisher_id'); } + protected $table = 'marketplace_publishers'; + + protected $fillable = ['name', 'slug', 'email', 'website', 'public_key', 'is_verified', 'metadata']; + + protected $casts = ['is_verified' => 'boolean', 'metadata' => 'array']; + + public function packages(): HasMany + { + return $this->hasMany(MarketplacePackage::class, 'publisher_id'); + } } diff --git a/Modules/Marketplace/Providers/MarketplaceServiceProvider.php b/Modules/Marketplace/Providers/MarketplaceServiceProvider.php index 9902c22..0edd17f 100644 --- a/Modules/Marketplace/Providers/MarketplaceServiceProvider.php +++ b/Modules/Marketplace/Providers/MarketplaceServiceProvider.php @@ -1,11 +1,16 @@ 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([\Modules\Marketplace\Console\Commands\MarketplaceSyncCommand::class, \Modules\Marketplace\Console\Commands\MarketplaceAuditCommand::class]); + $this->commands([MarketplaceSyncCommand::class, MarketplaceAuditCommand::class]); } } } diff --git a/Modules/Marketplace/Providers/RouteServiceProvider.php b/Modules/Marketplace/Providers/RouteServiceProvider.php index 1f248db..6bf0725 100644 --- a/Modules/Marketplace/Providers/RouteServiceProvider.php +++ b/Modules/Marketplace/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ 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'); + 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/Modules/Marketplace/Routes/web.php b/Modules/Marketplace/Routes/web.php index e92e32f..2ac0b30 100644 --- a/Modules/Marketplace/Routes/web.php +++ b/Modules/Marketplace/Routes/web.php @@ -1,12 +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'); + +Route::middleware(['auth', 'verified'])->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/Modules/Marketplace/Services/CompatibilityChecker.php b/Modules/Marketplace/Services/CompatibilityChecker.php index 5fd7f96..4ef2e12 100644 --- a/Modules/Marketplace/Services/CompatibilityChecker.php +++ b/Modules/Marketplace/Services/CompatibilityChecker.php @@ -1,17 +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]; + $errors = []; + $php = (string) ($manifest['php'] ?? '*'); + $laravel = (string) ($manifest['laravel'] ?? '*'); + $platform = (string) ($manifest['content_manager'] ?? '*'); + $platformVersion = (string) config('app.version', '1.0.0'); + if (! $this->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/Modules/Marketplace/Services/DependencyResolver.php b/Modules/Marketplace/Services/DependencyResolver.php index dc97614..02c711a 100644 --- a/Modules/Marketplace/Services/DependencyResolver.php +++ b/Modules/Marketplace/Services/DependencyResolver.php @@ -1,17 +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]; + $missing = []; + $incompatible = []; + foreach (($manifest['requires'] ?? []) as $package => $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]; + + return ['satisfied' => $missing === [] && $incompatible === [], 'missing' => $missing, 'incompatible' => $incompatible]; } } diff --git a/Modules/Marketplace/Services/MarketplaceManager.php b/Modules/Marketplace/Services/MarketplaceManager.php index c3d8741..a457800 100644 --- a/Modules/Marketplace/Services/MarketplaceManager.php +++ b/Modules/Marketplace/Services/MarketplaceManager.php @@ -1,39 +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(); + if (! config('marketplace.enabled')) { + throw new RuntimeException('The marketplace is disabled.'); + } + $manifest = $this->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 + + 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(); + $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 + + 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]); }); + 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 + + 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(); + 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()]); } - 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/Modules/Marketplace/Services/PackageManifest.php b/Modules/Marketplace/Services/PackageManifest.php index 6a7de47..2fecb73 100644 --- a/Modules/Marketplace/Services/PackageManifest.php +++ b/Modules/Marketplace/Services/PackageManifest.php @@ -1,22 +1,38 @@ 'The package manifest is not valid JSON.']); - $manifest=$decoded; + $decoded = json_decode($manifest, true); + if (! is_array($decoded)) { + throw ValidationException::withMessages(['manifest' => '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'] ?? '*'; + 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)); } + + public function fromFile(string $path): array + { + return $this->parse((string) file_get_contents($path)); + } } diff --git a/Modules/Marketplace/Services/PackageVerifier.php b/Modules/Marketplace/Services/PackageVerifier.php index 647e00c..1f909f7 100644 --- a/Modules/Marketplace/Services/PackageVerifier.php +++ b/Modules/Marketplace/Services/PackageVerifier.php @@ -1,16 +1,28 @@ true,'checksum'=>$actual,'signature_present'=>filled($manifest['signature'] ?? null)]; + if (! is_file($path)) { + throw new RuntimeException("Package archive [$path] does not exist."); + } + $actual = hash_file('sha256', $path); + $expected = $expectedChecksum ?: ($manifest['checksum'] ?? null); + if (config('marketplace.require_checksum', true) && blank($expected)) { + throw new RuntimeException('A SHA-256 checksum is required.'); + } + if (filled($expected) && ! hash_equals(strtolower((string) $expected), strtolower($actual))) { + throw new RuntimeException('Package checksum verification failed.'); + } + if (config('marketplace.require_signature', false) && blank($manifest['signature'] ?? null)) { + throw new RuntimeException('A package signature is required.'); + } + + return ['verified' => true, 'checksum' => $actual, 'signature_present' => filled($manifest['signature'] ?? null)]; } } diff --git a/Modules/Marketplace/Services/VersionConstraint.php b/Modules/Marketplace/Services/VersionConstraint.php index d0919c0..14e206c 100644 --- a/Modules/Marketplace/Services/VersionConstraint.php +++ b/Modules/Marketplace/Services/VersionConstraint.php @@ -1,22 +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; + $constraint = trim($constraint); + if ($constraint === '' || $constraint === '*') { + return true; } + foreach (preg_split('/\s*\|\|\s*/', $constraint) ?: [] as $alternative) { + $ok = true; + foreach (preg_split('/\s*,?\s+/', trim($alternative)) ?: [] as $part) { + if ($part === '') { + continue; + } + if (str_starts_with($part, '^')) { + $base = substr($part, 1); + $major = (int) explode('.', $base)[0]; + $ok = $ok && version_compare($version, $base, '>=') && 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/Modules/Marketplace/Tests/Feature/MarketplaceModuleTest.php b/Modules/Marketplace/Tests/Feature/MarketplaceModuleTest.php index e02803a..663809b 100644 --- a/Modules/Marketplace/Tests/Feature/MarketplaceModuleTest.php +++ b/Modules/Marketplace/Tests/Feature/MarketplaceModuleTest.php @@ -1,18 +1,23 @@ parse(['name'=>'vendor/example','type'=>'module','version'=>'1.0.0']); - $this->assertSame('module',$manifest['type']); $this->assertSame([], $manifest['requires']); + $manifest = app(PackageManifest::class)->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'=>'*']); + $result = app(CompatibilityChecker::class)->check(['php' => '*', 'laravel' => '*', 'content_manager' => '*']); $this->assertTrue($result['compatible']); } } diff --git a/Modules/Media/Database/Migrations/2026_07_23_000300_create_media_table.php b/Modules/Media/Database/Migrations/2026_07_23_000300_create_media_table.php index 4e874ad..581799e 100644 --- a/Modules/Media/Database/Migrations/2026_07_23_000300_create_media_table.php +++ b/Modules/Media/Database/Migrations/2026_07_23_000300_create_media_table.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('media', function (Blueprint $table): void { + $table->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/Modules/Media/Http/Controllers/MediaController.php b/Modules/Media/Http/Controllers/MediaController.php index f9dcb90..ee5c15e 100644 --- a/Modules/Media/Http/Controllers/MediaController.php +++ b/Modules/Media/Http/Controllers/MediaController.php @@ -1,8 +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.'); } + +use App\Http\Controllers\Controller; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; +use Modules\Media\Models\Media; + +final class MediaController extends Controller +{ + public function index() + { + return view('media::index', ['media' => 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/Modules/Media/Models/Media.php b/Modules/Media/Models/Media.php index 0a8c81e..56af7ee 100644 --- a/Modules/Media/Models/Media.php +++ b/Modules/Media/Models/Media.php @@ -1,3 +1,26 @@ $m->uuid ??= (string) Str::uuid()); } protected function casts(): array { return ['metadata'=>'array','size'=>'integer','sort_order'=>'integer']; } } + +namespace Modules\Media\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Str; + +final class Media extends Model +{ + use SoftDeletes; + + protected $table = 'media'; + + protected $guarded = []; + + protected static function booted(): void + { + self::creating(fn (self $m) => $m->uuid ??= (string) Str::uuid()); + } + + protected function casts(): array + { + return ['metadata' => 'array', 'size' => 'integer', 'sort_order' => 'integer']; + } +} diff --git a/Modules/Media/Routes/web.php b/Modules/Media/Routes/web.php index 4d9162f..172cab2 100644 --- a/Modules/Media/Routes/web.php +++ b/Modules/Media/Routes/web.php @@ -1,3 +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'); }); + +use Illuminate\Support\Facades\Route; +use Modules\Media\Http\Controllers\MediaController; + +Route::middleware(['auth', 'verified'])->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/Modules/Media/Support/ModuleInfo.php b/Modules/Media/Support/ModuleInfo.php index 78d45a0..91bfbd1 100644 --- a/Modules/Media/Support/ModuleInfo.php +++ b/Modules/Media/Support/ModuleInfo.php @@ -4,6 +4,13 @@ namespace Modules\Media\Support; final class ModuleInfo { - public static function name(): string { return 'Media'; } - public static function nameLower(): string { return 'media'; } + public static function name(): string + { + return 'Media'; + } + + public static function nameLower(): string + { + return 'media'; + } } diff --git a/Modules/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php b/Modules/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php index e49ccd6..6ab61c5 100644 --- a/Modules/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php +++ b/Modules/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php @@ -4,12 +4,23 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { - public function up(): void { Schema::create('menus', function (Blueprint $table): void { $table->id(); +return new class extends Migration +{ + public function up(): void + { + Schema::create('menus', function (Blueprint $table): void { + $table->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'); } + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('menus'); + } }; diff --git a/Modules/Menus/Http/Controllers/MenuController.php b/Modules/Menus/Http/Controllers/MenuController.php index 419dc64..1b43eb6 100644 --- a/Modules/Menus/Http/Controllers/MenuController.php +++ b/Modules/Menus/Http/Controllers/MenuController.php @@ -10,18 +10,55 @@ use Modules\Menus\Models\Menu; final class MenuController extends Controller { - public function index(): View { return view('menus::admin.index', ['records' => Menu::query()->latest()->paginate(20)]); } - public function create(): View { return view('menus::admin.form'); } - public function store(Request $request): RedirectResponse { $data = $request->validate(['name' => ['required', 'string', 'max:255'], + public function index(): View + { + return view('menus::admin.index', ['records' => Menu::query()->latest()->paginate(20)]); + } + + public function create(): View + { + return view('menus::admin.form'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'location' => ['required', 'string', 'max:255'], 'items' => ['nullable', 'array'], - 'enabled' => ['sometimes', 'boolean']]); if ($request->filled('items_json')) { $data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR); } - $data['enabled'] = $request->boolean('enabled'); Menu::create($data); return redirect()->route('menus.index')->with('status', 'Menu created.'); } - public function edit(Menu $menu): View { return view('menus::admin.form', ['record' => $menu]); } - public function update(Request $request, Menu $menu): RedirectResponse { $data = $request->validate(['name' => ['required', 'string', 'max:255'], + 'enabled' => ['sometimes', 'boolean']]); + if ($request->filled('items_json')) { + $data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR); + } + $data['enabled'] = $request->boolean('enabled'); + Menu::create($data); + + return redirect()->route('menus.index')->with('status', 'Menu created.'); + } + + public function edit(Menu $menu): View + { + return view('menus::admin.form', ['record' => $menu]); + } + + public function update(Request $request, Menu $menu): RedirectResponse + { + $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'location' => ['required', 'string', 'max:255'], 'items' => ['nullable', 'array'], - 'enabled' => ['sometimes', 'boolean']]); if ($request->filled('items_json')) { $data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR); } - $data['enabled'] = $request->boolean('enabled'); $menu->update($data); return redirect()->route('menus.index')->with('status', 'Menu updated.'); } - public function destroy(Menu $menu): RedirectResponse { $menu->delete(); return back()->with('status', 'Menu deleted.'); } + 'enabled' => ['sometimes', 'boolean']]); + if ($request->filled('items_json')) { + $data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR); + } + $data['enabled'] = $request->boolean('enabled'); + $menu->update($data); + + return redirect()->route('menus.index')->with('status', 'Menu updated.'); + } + + public function destroy(Menu $menu): RedirectResponse + { + $menu->delete(); + + return back()->with('status', 'Menu deleted.'); + } } diff --git a/Modules/Menus/Models/Menu.php b/Modules/Menus/Models/Menu.php index 6eb9153..0f957c6 100644 --- a/Modules/Menus/Models/Menu.php +++ b/Modules/Menus/Models/Menu.php @@ -9,12 +9,16 @@ use Illuminate\Database\Eloquent\SoftDeletes; final class Menu extends Model { use HasFactory, SoftDeletes; - protected $table = 'menus'; - protected $fillable = ['name', 'location', 'items', 'enabled']; - - protected function casts(): array { return [ - 'items' => 'array', - 'enabled' => 'boolean' - ]; } + protected $table = 'menus'; + + protected $fillable = ['name', 'location', 'items', 'enabled']; + + protected function casts(): array + { + return [ + 'items' => 'array', + 'enabled' => 'boolean', + ]; + } } diff --git a/Modules/Menus/Support/ModuleInfo.php b/Modules/Menus/Support/ModuleInfo.php index 4a845a6..e96b94b 100644 --- a/Modules/Menus/Support/ModuleInfo.php +++ b/Modules/Menus/Support/ModuleInfo.php @@ -2,4 +2,15 @@ namespace Modules\Menus\Support; -final class ModuleInfo { public static function name(): string { return 'Menus'; } public static function nameLower(): string { return 'menus'; } } +final class ModuleInfo +{ + public static function name(): string + { + return 'Menus'; + } + + public static function nameLower(): string + { + return 'menus'; + } +} diff --git a/Modules/Menus/Tests/Feature/ModuleBootTest.php b/Modules/Menus/Tests/Feature/ModuleBootTest.php index f45977b..516d963 100644 --- a/Modules/Menus/Tests/Feature/ModuleBootTest.php +++ b/Modules/Menus/Tests/Feature/ModuleBootTest.php @@ -2,4 +2,6 @@ use Illuminate\Support\Facades\Route; -it('registers the Menus administration route', function (): void { expect(Route::has('administration.menus.index'))->toBeTrue(); }); +it('registers the Menus administration route', function (): void { + expect(Route::has('administration.menus.index'))->toBeTrue(); +}); diff --git a/Modules/News/Config/config.php b/Modules/News/Config/config.php index 0db6d9a..3909f94 100644 --- a/Modules/News/Config/config.php +++ b/Modules/News/Config/config.php @@ -1,2 +1,3 @@ 'News']; diff --git a/Modules/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php b/Modules/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php index d1a675a..d939ffc 100644 --- a/Modules/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php +++ b/Modules/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('news_articles', function (Blueprint $table): void { + $table->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/Modules/News/Http/Controllers/NewsController.php b/Modules/News/Http/Controllers/NewsController.php index 34e9073..cc69cc2 100644 --- a/Modules/News/Http/Controllers/NewsController.php +++ b/Modules/News/Http/Controllers/NewsController.php @@ -1,4 +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; } } + +use App\Http\Controllers\Controller; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\View\View; +use Modules\Authors\Models\Author; +use Modules\Categories\Models\Category; +use Modules\News\Models\News; +use Modules\Tags\Models\Tag; + +final class NewsController extends Controller +{ + public function index(): View + { + return view('news::admin.index', ['records' => 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/Modules/News/Http/Controllers/PublicNewsController.php b/Modules/News/Http/Controllers/PublicNewsController.php index c0fef89..5783072 100644 --- a/Modules/News/Http/Controllers/PublicNewsController.php +++ b/Modules/News/Http/Controllers/PublicNewsController.php @@ -1,3 +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()]); } } + +namespace Modules\News\Http\Controllers; + +use App\Http\Controllers\Controller; +use Illuminate\View\View; +use Modules\News\Models\News; + +final class PublicNewsController extends Controller +{ + public function index(): View + { + return view('news::public.index', ['records' => 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/Modules/News/Models/News.php b/Modules/News/Models/News.php index c481083..586c952 100644 --- a/Modules/News/Models/News.php +++ b/Modules/News/Models/News.php @@ -1,5 +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())); } } +use Modules\Tags\Models\Tag; +use Modules\Workflow\Concerns\HasWorkflow; + +final class News extends Model +{ + use BelongsToSites, HasTranslations, HasWorkflow; + + protected $table = 'news_articles'; + + protected $fillable = ['author_id', 'category_id', 'title', 'slug', 'excerpt', 'content', 'featured_image', 'status', 'is_featured', 'published_at', 'expires_at', 'meta_title', 'meta_description']; + + protected function casts(): array + { + return ['is_featured' => '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/Modules/News/Providers/NewsServiceProvider.php b/Modules/News/Providers/NewsServiceProvider.php index b791919..959998d 100644 --- a/Modules/News/Providers/NewsServiceProvider.php +++ b/Modules/News/Providers/NewsServiceProvider.php @@ -1,6 +1,9 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('News', 'Config/config.php'), 'news'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('News', 'Database/Migrations')); diff --git a/Modules/News/Providers/RouteServiceProvider.php b/Modules/News/Providers/RouteServiceProvider.php index 4a8265e..cfa0850 100644 --- a/Modules/News/Providers/RouteServiceProvider.php +++ b/Modules/News/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ prefix('administration/news')->name('administration.news.')->group(function (): void { Route::resource('/', NewsController::class)->parameters([''=>'news_article'])->except('show'); }); -Route::get('/news',[PublicNewsController::class,'index'])->name('news.public.index'); Route::get('/news/{slug}',[PublicNewsController::class,'show'])->name('news.public.show'); + +use Illuminate\Support\Facades\Route; +use Modules\News\Http\Controllers\NewsController; +use Modules\News\Http\Controllers\PublicNewsController; + +Route::middleware(['auth', 'verified'])->prefix('administration/news')->name('administration.news.')->group(function (): void { + Route::resource('/', NewsController::class)->parameters(['' => 'news_article'])->except('show'); +}); +Route::get('/news', [PublicNewsController::class, 'index'])->name('news.public.index'); +Route::get('/news/{slug}', [PublicNewsController::class, 'show'])->name('news.public.show'); diff --git a/Modules/News/Tests/Feature/ModuleBootTest.php b/Modules/News/Tests/Feature/ModuleBootTest.php index 443ef12..dbe1c8a 100644 --- a/Modules/News/Tests/Feature/ModuleBootTest.php +++ b/Modules/News/Tests/Feature/ModuleBootTest.php @@ -1,6 +1,9 @@ 'Newsletter']; diff --git a/Modules/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php b/Modules/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php index 6253e31..613856a 100644 --- a/Modules/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php +++ b/Modules/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('newsletter_lists', function (Blueprint $t): void { + $t->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/Modules/Newsletter/Http/Controllers/NewsletterController.php b/Modules/Newsletter/Http/Controllers/NewsletterController.php index b815a5b..0522ce1 100644 --- a/Modules/Newsletter/Http/Controllers/NewsletterController.php +++ b/Modules/Newsletter/Http/Controllers/NewsletterController.php @@ -1,3 +1,41 @@ 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 destroySubscriber(Subscriber $subscriber): RedirectResponse { $subscriber->delete(); return back()->with('status','Subscriber deleted.'); } } + +namespace Modules\Newsletter\Http\Controllers; + +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Str; +use Illuminate\View\View; +use Modules\Newsletter\Models\Campaign; +use Modules\Newsletter\Models\MailingList; +use Modules\Newsletter\Models\Subscriber; + +final class NewsletterController +{ + public function index(): View + { + return view('newsletter::admin.index', ['subscribers' => 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 destroySubscriber(Subscriber $subscriber): RedirectResponse + { + $subscriber->delete(); + + return back()->with('status', 'Subscriber deleted.'); + } +} diff --git a/Modules/Newsletter/Models/Campaign.php b/Modules/Newsletter/Models/Campaign.php index dd60a87..924a6a8 100644 --- a/Modules/Newsletter/Models/Campaign.php +++ b/Modules/Newsletter/Models/Campaign.php @@ -1,3 +1,17 @@ 'datetime','sent_at'=>'datetime']; } } + +namespace Modules\Newsletter\Models; + +use Illuminate\Database\Eloquent\Model; + +final class Campaign extends Model +{ + protected $table = 'newsletter_campaigns'; + + protected $fillable = ['list_id', 'name', 'subject', 'content', 'status', 'scheduled_at', 'sent_at', 'sent_count', 'open_count', 'click_count']; + + protected function casts(): array + { + return ['scheduled_at' => 'datetime', 'sent_at' => 'datetime']; + } +} diff --git a/Modules/Newsletter/Models/MailingList.php b/Modules/Newsletter/Models/MailingList.php index 8df61d6..786b233 100644 --- a/Modules/Newsletter/Models/MailingList.php +++ b/Modules/Newsletter/Models/MailingList.php @@ -1,3 +1,17 @@ 'boolean']; } } + +namespace Modules\Newsletter\Models; + +use Illuminate\Database\Eloquent\Model; + +final class MailingList extends Model +{ + protected $table = 'newsletter_lists'; + + protected $fillable = ['name', 'description', 'is_active']; + + protected function casts(): array + { + return ['is_active' => 'boolean']; + } +} diff --git a/Modules/Newsletter/Models/Subscriber.php b/Modules/Newsletter/Models/Subscriber.php index 9ac6508..9d0bd30 100644 --- a/Modules/Newsletter/Models/Subscriber.php +++ b/Modules/Newsletter/Models/Subscriber.php @@ -1,3 +1,17 @@ 'datetime','unsubscribed_at'=>'datetime','metadata'=>'array']; } } + +namespace Modules\Newsletter\Models; + +use Illuminate\Database\Eloquent\Model; + +final class Subscriber extends Model +{ + protected $table = 'newsletter_subscribers'; + + protected $fillable = ['email', 'name', 'status', 'confirmation_token', 'confirmed_at', 'unsubscribed_at', 'metadata']; + + protected function casts(): array + { + return ['confirmed_at' => 'datetime', 'unsubscribed_at' => 'datetime', 'metadata' => 'array']; + } +} diff --git a/Modules/Newsletter/Providers/NewsletterServiceProvider.php b/Modules/Newsletter/Providers/NewsletterServiceProvider.php index 07ed786..d10726f 100644 --- a/Modules/Newsletter/Providers/NewsletterServiceProvider.php +++ b/Modules/Newsletter/Providers/NewsletterServiceProvider.php @@ -1,6 +1,9 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('Newsletter', 'Config/config.php'), 'newsletter'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Newsletter', 'Database/Migrations')); diff --git a/Modules/Newsletter/Providers/RouteServiceProvider.php b/Modules/Newsletter/Providers/RouteServiceProvider.php index 2ee035c..47472fc 100644 --- a/Modules/Newsletter/Providers/RouteServiceProvider.php +++ b/Modules/Newsletter/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ 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'); }); + +use Illuminate\Support\Facades\Route; +use Modules\Newsletter\Http\Controllers\NewsletterController; + +Route::middleware(['auth', 'verified'])->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'); +}); diff --git a/Modules/Newsletter/Tests/Feature/ModuleBootTest.php b/Modules/Newsletter/Tests/Feature/ModuleBootTest.php index 863df14..d76ba2b 100644 --- a/Modules/Newsletter/Tests/Feature/ModuleBootTest.php +++ b/Modules/Newsletter/Tests/Feature/ModuleBootTest.php @@ -1,6 +1,9 @@ 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']); }); } }; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::table('pages', function (Blueprint $table): void { + $table->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/Modules/Pages/Http/Controllers/PageController.php b/Modules/Pages/Http/Controllers/PageController.php index 8928a75..166f313 100644 --- a/Modules/Pages/Http/Controllers/PageController.php +++ b/Modules/Pages/Http/Controllers/PageController.php @@ -10,22 +10,55 @@ use Modules\Pages\Models\Page; final class PageController extends Controller { - public function index(): View { return view('pages::admin.index', ['records' => Page::query()->latest()->paginate(20)]); } - public function create(): View { return view('pages::admin.form'); } - public function store(Request $request): RedirectResponse { $data = $request->validate(['title' => ['required', 'string', 'max:255'], + public function index(): View + { + return view('pages::admin.index', ['records' => Page::query()->latest()->paginate(20)]); + } + + public function create(): View + { + return view('pages::admin.form'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(['title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'summary' => ['nullable', 'string'], 'content' => ['nullable', 'string'], 'status' => ['required', 'string', 'max:255'], 'published_at' => ['nullable', 'date'], - 'is_home' => ['sometimes', 'boolean']]); $data['is_home'] = $request->boolean('is_home'); Page::create($data); return redirect()->route('pages.index')->with('status', 'Page created.'); } - public function edit(Page $page): View { return view('pages::admin.form', ['record' => $page]); } - public function update(Request $request, Page $page): RedirectResponse { $data = $request->validate(['title' => ['required', 'string', 'max:255'], + 'is_home' => ['sometimes', 'boolean']]); + $data['is_home'] = $request->boolean('is_home'); + Page::create($data); + + return redirect()->route('pages.index')->with('status', 'Page created.'); + } + + public function edit(Page $page): View + { + return view('pages::admin.form', ['record' => $page]); + } + + public function update(Request $request, Page $page): RedirectResponse + { + $data = $request->validate(['title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'summary' => ['nullable', 'string'], 'content' => ['nullable', 'string'], 'status' => ['required', 'string', 'max:255'], 'published_at' => ['nullable', 'date'], - 'is_home' => ['sometimes', 'boolean']]); $data['is_home'] = $request->boolean('is_home'); $page->update($data); return redirect()->route('pages.index')->with('status', 'Page updated.'); } - public function destroy(Page $page): RedirectResponse { $page->delete(); return back()->with('status', 'Page deleted.'); } + 'is_home' => ['sometimes', 'boolean']]); + $data['is_home'] = $request->boolean('is_home'); + $page->update($data); + + return redirect()->route('pages.index')->with('status', 'Page updated.'); + } + + public function destroy(Page $page): RedirectResponse + { + $page->delete(); + + return back()->with('status', 'Page deleted.'); + } } diff --git a/Modules/Pages/Http/Controllers/PublicPageController.php b/Modules/Pages/Http/Controllers/PublicPageController.php index fd3e0df..c9e531f 100644 --- a/Modules/Pages/Http/Controllers/PublicPageController.php +++ b/Modules/Pages/Http/Controllers/PublicPageController.php @@ -1,6 +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')); } } + +final class PublicPageController extends Controller +{ + public function show(string $slug): View + { + $page = Page::query()->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/Modules/Pages/Models/Page.php b/Modules/Pages/Models/Page.php index 28a2084..84f31e9 100644 --- a/Modules/Pages/Models/Page.php +++ b/Modules/Pages/Models/Page.php @@ -5,19 +5,23 @@ namespace Modules\Pages\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; -use Modules\Workflow\Concerns\HasWorkflow; use Modules\Localization\Concerns\HasTranslations; use Modules\Sites\Concerns\BelongsToSites; +use Modules\Workflow\Concerns\HasWorkflow; final class Page extends Model { use BelongsToSites, HasFactory, HasTranslations, HasWorkflow, SoftDeletes; - protected $table = 'pages'; - protected $fillable = ['title', 'slug', 'summary', 'content', 'status', 'published_at', 'is_home']; - - protected function casts(): array { return [ - 'published_at' => 'datetime', - 'is_home' => 'boolean' - ]; } + protected $table = 'pages'; + + protected $fillable = ['title', 'slug', 'summary', 'content', 'status', 'published_at', 'is_home']; + + protected function casts(): array + { + return [ + 'published_at' => 'datetime', + 'is_home' => 'boolean', + ]; + } } diff --git a/Modules/Pages/Routes/web.php b/Modules/Pages/Routes/web.php index c3eef5b..bef1fcf 100644 --- a/Modules/Pages/Routes/web.php +++ b/Modules/Pages/Routes/web.php @@ -2,9 +2,10 @@ use Illuminate\Support\Facades\Route; use Modules\Pages\Http\Controllers\PageController; +use Modules\Pages\Http\Controllers\PublicPageController; Route::middleware(['auth', 'verified'])->prefix('administration/pages')->name('administration.pages.')->group(function (): void { Route::resource('/', PageController::class)->parameters(['' => 'page'])->except('show'); }); -Route::get('/pages/{slug}', [Modules\Pages\Http\Controllers\PublicPageController::class, 'show'])->name('pages.public.show'); +Route::get('/pages/{slug}', [PublicPageController::class, 'show'])->name('pages.public.show'); diff --git a/Modules/Pages/Tests/Feature/ModuleBootTest.php b/Modules/Pages/Tests/Feature/ModuleBootTest.php index 2dd98b2..2a22cc4 100644 --- a/Modules/Pages/Tests/Feature/ModuleBootTest.php +++ b/Modules/Pages/Tests/Feature/ModuleBootTest.php @@ -2,4 +2,6 @@ use Illuminate\Support\Facades\Route; -it('registers the Pages administration route', function (): void { expect(Route::has('administration.pages.index'))->toBeTrue(); }); +it('registers the Pages administration route', function (): void { + expect(Route::has('administration.pages.index'))->toBeTrue(); +}); diff --git a/Modules/Products/Database/Migrations/2026_07_24_105054_create_products_table.php b/Modules/Products/Database/Migrations/2026_07_24_105054_create_products_table.php index 1d27a88..a009d1a 100644 --- a/Modules/Products/Database/Migrations/2026_07_24_105054_create_products_table.php +++ b/Modules/Products/Database/Migrations/2026_07_24_105054_create_products_table.php @@ -4,7 +4,20 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { - public function up(): void { Schema::create('products', function (Blueprint $table): void { $table->id(); $table->string('name'); $table->boolean('is_active')->default(true); $table->timestamps(); }); } - public function down(): void { Schema::dropIfExists('products'); } +return new class extends Migration +{ + public function up(): void + { + Schema::create('products', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('products'); + } }; diff --git a/Modules/Products/Http/Controllers/ProductController.php b/Modules/Products/Http/Controllers/ProductController.php index dd70055..c3ccf6f 100644 --- a/Modules/Products/Http/Controllers/ProductController.php +++ b/Modules/Products/Http/Controllers/ProductController.php @@ -17,6 +17,7 @@ final class ProductController public function store(StoreProductRequest $request): RedirectResponse { Product::query()->create($request->validated()); + return back()->with('status', 'Product created.'); } } diff --git a/Modules/Products/Http/Requests/StoreProductRequest.php b/Modules/Products/Http/Requests/StoreProductRequest.php index ba5e5dc..e41dc6c 100644 --- a/Modules/Products/Http/Requests/StoreProductRequest.php +++ b/Modules/Products/Http/Requests/StoreProductRequest.php @@ -6,6 +6,13 @@ use Illuminate\Foundation\Http\FormRequest; final class StoreProductRequest extends FormRequest { - public function authorize(): bool { return $this->user() !== null; } - public function rules(): array { return ['name' => ['required', 'string', 'max:255'], 'is_active' => ['sometimes', 'boolean']]; } + public function authorize(): bool + { + return $this->user() !== null; + } + + public function rules(): array + { + return ['name' => ['required', 'string', 'max:255'], 'is_active' => ['sometimes', 'boolean']]; + } } diff --git a/Modules/Products/Tests/Feature/ProductCrudTest.php b/Modules/Products/Tests/Feature/ProductCrudTest.php index 523eb1d..f596dc9 100644 --- a/Modules/Products/Tests/Feature/ProductCrudTest.php +++ b/Modules/Products/Tests/Feature/ProductCrudTest.php @@ -2,4 +2,6 @@ use Modules\Products\Models\Product; -it('can create a Product', function (): void { expect(Product::query()->create(['name' => 'Example']))->toBeInstanceOf(Product::class); }); +it('can create a Product', function (): void { + expect(Product::query()->create(['name' => 'Example']))->toBeInstanceOf(Product::class); +}); diff --git a/Modules/Products/Tests/Feature/ProductsModuleBootTest.php b/Modules/Products/Tests/Feature/ProductsModuleBootTest.php index 57b18f9..f89049e 100644 --- a/Modules/Products/Tests/Feature/ProductsModuleBootTest.php +++ b/Modules/Products/Tests/Feature/ProductsModuleBootTest.php @@ -1,5 +1,7 @@ providerIsLoaded(Modules\Products\Providers\ProductsServiceProvider::class))->toBeTrue(); + expect(app()->providerIsLoaded(ProductsServiceProvider::class))->toBeTrue(); }); diff --git a/Modules/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php b/Modules/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php index 9708c38..5635594 100644 --- a/Modules/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php +++ b/Modules/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php @@ -4,12 +4,23 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { - public function up(): void { Schema::create('redirects', function (Blueprint $table): void { $table->id(); +return new class extends Migration +{ + public function up(): void + { + Schema::create('redirects', function (Blueprint $table): void { + $table->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'); } + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('redirects'); + } }; diff --git a/Modules/Redirects/Http/Controllers/RedirectController.php b/Modules/Redirects/Http/Controllers/RedirectController.php index 9b45119..d396875 100644 --- a/Modules/Redirects/Http/Controllers/RedirectController.php +++ b/Modules/Redirects/Http/Controllers/RedirectController.php @@ -10,16 +10,49 @@ use Modules\Redirects\Models\Redirect; final class RedirectController extends Controller { - public function index(): View { return view('redirects::admin.index', ['records' => 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'], + public function index(): View + { + return view('redirects::admin.index', ['records' => 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('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'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + Redirect::create($data); + + return redirect()->route('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('redirects.index')->with('status', 'Redirect updated.'); } - public function destroy(Redirect $redirect): RedirectResponse { $redirect->delete(); return back()->with('status', 'Redirect deleted.'); } + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + $redirect->update($data); + + return redirect()->route('redirects.index')->with('status', 'Redirect updated.'); + } + + public function destroy(Redirect $redirect): RedirectResponse + { + $redirect->delete(); + + return back()->with('status', 'Redirect deleted.'); + } } diff --git a/Modules/Redirects/Models/Redirect.php b/Modules/Redirects/Models/Redirect.php index cb2b84f..ba4db4e 100644 --- a/Modules/Redirects/Models/Redirect.php +++ b/Modules/Redirects/Models/Redirect.php @@ -9,12 +9,16 @@ use Illuminate\Database\Eloquent\SoftDeletes; final class Redirect extends Model { use HasFactory, SoftDeletes; - protected $table = 'redirects'; - protected $fillable = ['source', 'destination', 'status_code', 'enabled']; - - protected function casts(): array { return [ - 'status_code' => 'integer', - 'enabled' => 'boolean' - ]; } + protected $table = 'redirects'; + + protected $fillable = ['source', 'destination', 'status_code', 'enabled']; + + protected function casts(): array + { + return [ + 'status_code' => 'integer', + 'enabled' => 'boolean', + ]; + } } diff --git a/Modules/Redirects/Support/ModuleInfo.php b/Modules/Redirects/Support/ModuleInfo.php index 3f265d0..a226dac 100644 --- a/Modules/Redirects/Support/ModuleInfo.php +++ b/Modules/Redirects/Support/ModuleInfo.php @@ -2,4 +2,15 @@ namespace Modules\Redirects\Support; -final class ModuleInfo { public static function name(): string { return 'Redirects'; } public static function nameLower(): string { return 'redirects'; } } +final class ModuleInfo +{ + public static function name(): string + { + return 'Redirects'; + } + + public static function nameLower(): string + { + return 'redirects'; + } +} diff --git a/Modules/Redirects/Tests/Feature/ModuleBootTest.php b/Modules/Redirects/Tests/Feature/ModuleBootTest.php index 9c0f403..31baa84 100644 --- a/Modules/Redirects/Tests/Feature/ModuleBootTest.php +++ b/Modules/Redirects/Tests/Feature/ModuleBootTest.php @@ -2,4 +2,6 @@ use Illuminate\Support\Facades\Route; -it('registers the Redirects administration route', function (): void { expect(Route::has('administration.redirects.index'))->toBeTrue(); }); +it('registers the Redirects administration route', function (): void { + expect(Route::has('administration.redirects.index'))->toBeTrue(); +}); diff --git a/Modules/Roles/Http/Controllers/RoleController.php b/Modules/Roles/Http/Controllers/RoleController.php index f56c8d0..d5b0e16 100644 --- a/Modules/Roles/Http/Controllers/RoleController.php +++ b/Modules/Roles/Http/Controllers/RoleController.php @@ -10,11 +10,49 @@ use Spatie\Permission\Models\Role; final class RoleController extends Controller { - public function index() { return view('roles::index',['roles'=>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']]); } + public function index() + { + return view('roles::index', ['roles' => 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/Modules/Roles/Routes/web.php b/Modules/Roles/Routes/web.php index fd675d0..f611599 100644 --- a/Modules/Roles/Routes/web.php +++ b/Modules/Roles/Routes/web.php @@ -1,3 +1,8 @@ prefix('administration/roles')->name('administration.roles.')->group(function (): void { Route::resource('', RoleController::class)->parameters([''=>'role'])->except(['show']); }); + +use Illuminate\Support\Facades\Route; +use Modules\Roles\Http\Controllers\RoleController; + +Route::middleware(['auth', 'verified'])->prefix('administration/roles')->name('administration.roles.')->group(function (): void { + Route::resource('', RoleController::class)->parameters(['' => 'role'])->except(['show']); +}); diff --git a/Modules/Roles/Support/ModuleInfo.php b/Modules/Roles/Support/ModuleInfo.php index bca87db..339da0a 100644 --- a/Modules/Roles/Support/ModuleInfo.php +++ b/Modules/Roles/Support/ModuleInfo.php @@ -4,6 +4,13 @@ namespace Modules\Roles\Support; final class ModuleInfo { - public static function name(): string { return 'Roles'; } - public static function nameLower(): string { return 'roles'; } + public static function name(): string + { + return 'Roles'; + } + + public static function nameLower(): string + { + return 'roles'; + } } diff --git a/Modules/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php b/Modules/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php index 749990e..ca700a7 100644 --- a/Modules/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php +++ b/Modules/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('search_index', function (Blueprint $table): void { + $table->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/Modules/Search/Http/Controllers/SearchController.php b/Modules/Search/Http/Controllers/SearchController.php index b74a619..9c591c6 100644 --- a/Modules/Search/Http/Controllers/SearchController.php +++ b/Modules/Search/Http/Controllers/SearchController.php @@ -1,4 +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')); } } + +use App\Http\Controllers\Controller; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; + +final class SearchController extends Controller +{ + public function index(Request $request) + { + $q = trim((string) $request->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/Modules/Search/Routes/web.php b/Modules/Search/Routes/web.php index 1a4abbc..ad6c1f7 100644 --- a/Modules/Search/Routes/web.php +++ b/Modules/Search/Routes/web.php @@ -1,3 +1,6 @@ get('administration/search',[SearchController::class,'index'])->name('administration.search.index'); + +use Illuminate\Support\Facades\Route; +use Modules\Search\Http\Controllers\SearchController; + +Route::middleware(['auth', 'verified'])->get('administration/search', [SearchController::class, 'index'])->name('administration.search.index'); diff --git a/Modules/Search/Support/ModuleInfo.php b/Modules/Search/Support/ModuleInfo.php index 267c39a..98f93c7 100644 --- a/Modules/Search/Support/ModuleInfo.php +++ b/Modules/Search/Support/ModuleInfo.php @@ -4,6 +4,13 @@ namespace Modules\Search\Support; final class ModuleInfo { - public static function name(): string { return 'Search'; } - public static function nameLower(): string { return 'search'; } + public static function name(): string + { + return 'Search'; + } + + public static function nameLower(): string + { + return 'search'; + } } diff --git a/Modules/Security/Config/config.php b/Modules/Security/Config/config.php index 55cce9d..b4e3156 100644 --- a/Modules/Security/Config/config.php +++ b/Modules/Security/Config/config.php @@ -1,4 +1,5 @@ (bool) env('SECURITY_RECORD_AUTH_EVENTS', true), 'trusted_device_days' => (int) env('SECURITY_TRUSTED_DEVICE_DAYS', 30), diff --git a/Modules/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php b/Modules/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php index 9b8dd41..a393e34 100644 --- a/Modules/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php +++ b/Modules/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php @@ -1,45 +1,76 @@ id(); $table->string('name')->default('Default security policy'); + $table->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(); + $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(); + $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']); + $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']); + $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'); + Schema::dropIfExists('security_ip_rules'); + Schema::dropIfExists('security_devices'); + Schema::dropIfExists('security_login_events'); + Schema::dropIfExists('security_policies'); } }; diff --git a/Modules/Security/Http/Controllers/SecurityController.php b/Modules/Security/Http/Controllers/SecurityController.php index 6277cbc..77e2633 100644 --- a/Modules/Security/Http/Controllers/SecurityController.php +++ b/Modules/Security/Http/Controllers/SecurityController.php @@ -1,5 +1,7 @@ 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()]); + return view('security::admin.index', ['policy' => 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.'); + $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.'); + 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.'); } - 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/Modules/Security/Http/Middleware/EnforceIpRestrictions.php b/Modules/Security/Http/Middleware/EnforceIpRestrictions.php index d6129f1..e009e1e 100644 --- a/Modules/Security/Http/Middleware/EnforceIpRestrictions.php +++ b/Modules/Security/Http/Middleware/EnforceIpRestrictions.php @@ -1,15 +1,20 @@ rules->isBlocked((string)$request->ip(),$scope),403,'Access is not permitted from this IP address.'); + abort_if($this->rules->isBlocked((string) $request->ip(), $scope), 403, 'Access is not permitted from this IP address.'); + return $next($request); } } diff --git a/Modules/Security/Listeners/RecordFailedLogin.php b/Modules/Security/Listeners/RecordFailedLogin.php index d93b948..6bcd118 100644 --- a/Modules/Security/Listeners/RecordFailedLogin.php +++ b/Modules/Security/Listeners/RecordFailedLogin.php @@ -1,13 +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()]); + if (! config('security.record_authentication_events', true)) { + return; + } + $request = request(); + $user = property_exists($event, 'user') ? $event->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/Modules/Security/Listeners/RecordLogin.php b/Modules/Security/Listeners/RecordLogin.php index 566499b..83face4 100644 --- a/Modules/Security/Listeners/RecordLogin.php +++ b/Modules/Security/Listeners/RecordLogin.php @@ -1,13 +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()]); + if (! config('security.record_authentication_events', true)) { + return; + } + $request = request(); + $user = property_exists($event, 'user') ? $event->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/Modules/Security/Listeners/RecordLogout.php b/Modules/Security/Listeners/RecordLogout.php index d5ed8ab..f29feed 100644 --- a/Modules/Security/Listeners/RecordLogout.php +++ b/Modules/Security/Listeners/RecordLogout.php @@ -1,13 +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()]); + if (! config('security.record_authentication_events', true)) { + return; + } + $request = request(); + $user = property_exists($event, 'user') ? $event->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/Modules/Security/Models/IpRule.php b/Modules/Security/Models/IpRule.php index 0136f27..2996dfe 100644 --- a/Modules/Security/Models/IpRule.php +++ b/Modules/Security/Models/IpRule.php @@ -1,9 +1,14 @@ 'boolean','applies_to_login'=>'boolean','is_active'=>'boolean']; + protected $table = 'security_ip_rules'; + + protected $fillable = ['name', 'type', 'address', 'description', 'applies_to_admin', 'applies_to_login', 'is_active', 'created_by']; + + protected $casts = ['applies_to_admin' => 'boolean', 'applies_to_login' => 'boolean', 'is_active' => 'boolean']; } diff --git a/Modules/Security/Models/LoginEvent.php b/Modules/Security/Models/LoginEvent.php index 616ddce..1fce0ab 100644 --- a/Modules/Security/Models/LoginEvent.php +++ b/Modules/Security/Models/LoginEvent.php @@ -1,9 +1,14 @@ 'array','occurred_at'=>'datetime']; + protected $table = 'security_login_events'; + + protected $fillable = ['user_id', 'email', 'event', 'ip_address', 'user_agent', 'session_id', 'reason', 'metadata', 'occurred_at']; + + protected $casts = ['metadata' => 'array', 'occurred_at' => 'datetime']; } diff --git a/Modules/Security/Models/SecurityPolicy.php b/Modules/Security/Models/SecurityPolicy.php index 5294683..e790c60 100644 --- a/Modules/Security/Models/SecurityPolicy.php +++ b/Modules/Security/Models/SecurityPolicy.php @@ -1,8 +1,12 @@ 'boolean','require_numbers'=>'boolean','require_symbols'=>'boolean','require_mfa'=>'boolean','single_session'=>'boolean','is_active'=>'boolean']; + protected $fillable = ['name', 'minimum_password_length', 'require_mixed_case', 'require_numbers', 'require_symbols', 'require_mfa', 'password_expiry_days', 'password_history', 'max_failed_logins', 'lockout_minutes', 'session_idle_minutes', 'single_session', 'is_active']; + + protected $casts = ['require_mixed_case' => 'boolean', 'require_numbers' => 'boolean', 'require_symbols' => 'boolean', 'require_mfa' => 'boolean', 'single_session' => 'boolean', 'is_active' => 'boolean']; } diff --git a/Modules/Security/Models/TrustedDevice.php b/Modules/Security/Models/TrustedDevice.php index 5a4dad3..6777067 100644 --- a/Modules/Security/Models/TrustedDevice.php +++ b/Modules/Security/Models/TrustedDevice.php @@ -1,9 +1,14 @@ 'datetime','last_seen_at'=>'datetime','revoked_at'=>'datetime']; + protected $table = 'security_devices'; + + protected $fillable = ['user_id', 'name', 'fingerprint', 'ip_address', 'user_agent', 'trusted_until', 'last_seen_at', 'revoked_at']; + + protected $casts = ['trusted_until' => 'datetime', 'last_seen_at' => 'datetime', 'revoked_at' => 'datetime']; } diff --git a/Modules/Security/Providers/RouteServiceProvider.php b/Modules/Security/Providers/RouteServiceProvider.php index baca617..ed3f4c3 100644 --- a/Modules/Security/Providers/RouteServiceProvider.php +++ b/Modules/Security/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ 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')); diff --git a/Modules/Security/Routes/web.php b/Modules/Security/Routes/web.php index ce84bd1..b92281e 100644 --- a/Modules/Security/Routes/web.php +++ b/Modules/Security/Routes/web.php @@ -1,10 +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'); + +Route::prefix('administration/security')->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/Modules/Security/Services/IpRuleService.php b/Modules/Security/Services/IpRuleService.php index ca2ea30..490f000 100644 --- a/Modules/Security/Services/IpRuleService.php +++ b/Modules/Security/Services/IpRuleService.php @@ -1,24 +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); + $column = $scope === 'login' ? 'applies_to_login' : 'applies_to_admin'; + $rules = IpRule::query()->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/Modules/Security/Tests/Feature/ModuleBootTest.php b/Modules/Security/Tests/Feature/ModuleBootTest.php index dbea313..7f2e420 100644 --- a/Modules/Security/Tests/Feature/ModuleBootTest.php +++ b/Modules/Security/Tests/Feature/ModuleBootTest.php @@ -1,8 +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()); } + public function test_security_route_is_registered(): void + { + $this->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/Modules/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php b/Modules/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php index 7d9f77a..1867c1e 100644 --- a/Modules/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php +++ b/Modules/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('seo_metadata', function (Blueprint $table): void { + $table->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/Modules/Seo/Http/Controllers/SeoController.php b/Modules/Seo/Http/Controllers/SeoController.php index 24090e9..99349c0 100644 --- a/Modules/Seo/Http/Controllers/SeoController.php +++ b/Modules/Seo/Http/Controllers/SeoController.php @@ -1,7 +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.'); } + +use App\Http\Controllers\Controller; +use Illuminate\Http\Request; +use Modules\Settings\Models\Setting; + +final class SeoController extends Controller +{ + public function index() + { + $values = Setting::where('group', 'seo')->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/Modules/Seo/Models/SeoMetadata.php b/Modules/Seo/Models/SeoMetadata.php index 96d44e6..fa17538 100644 --- a/Modules/Seo/Models/SeoMetadata.php +++ b/Modules/Seo/Models/SeoMetadata.php @@ -1,3 +1,17 @@ 'array','include_in_sitemap'=>'boolean','sitemap_priority'=>'decimal:1']; } } + +namespace Modules\Seo\Models; + +use Illuminate\Database\Eloquent\Model; + +final class SeoMetadata extends Model +{ + protected $table = 'seo_metadata'; + + protected $guarded = []; + + protected function casts(): array + { + return ['schema_data' => 'array', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1']; + } +} diff --git a/Modules/Seo/Routes/web.php b/Modules/Seo/Routes/web.php index 6d8e273..fa3652f 100644 --- a/Modules/Seo/Routes/web.php +++ b/Modules/Seo/Routes/web.php @@ -1,3 +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'); }); + +use Illuminate\Support\Facades\Route; +use Modules\Seo\Http\Controllers\SeoController; + +Route::middleware(['auth', 'verified'])->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/Modules/Seo/Support/ModuleInfo.php b/Modules/Seo/Support/ModuleInfo.php index 6884b29..ac5145e 100644 --- a/Modules/Seo/Support/ModuleInfo.php +++ b/Modules/Seo/Support/ModuleInfo.php @@ -4,6 +4,13 @@ namespace Modules\Seo\Support; final class ModuleInfo { - public static function name(): string { return 'Seo'; } - public static function nameLower(): string { return 'seo'; } + public static function name(): string + { + return 'Seo'; + } + + public static function nameLower(): string + { + return 'seo'; + } } diff --git a/Modules/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php b/Modules/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php index 04de163..62b7134 100644 --- a/Modules/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php +++ b/Modules/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php @@ -4,13 +4,24 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { - public function up(): void { +return new class extends Migration +{ + public function up(): void + { Schema::create('settings', function (Blueprint $table): void { - $table->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']); + $table->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'); } + + public function down(): void + { + Schema::dropIfExists('settings'); + } }; diff --git a/Modules/Settings/Http/Controllers/SettingController.php b/Modules/Settings/Http/Controllers/SettingController.php index e4aacfd..503c331 100644 --- a/Modules/Settings/Http/Controllers/SettingController.php +++ b/Modules/Settings/Http/Controllers/SettingController.php @@ -1,7 +1,27 @@ orderBy('group')->orderBy('key')->get(); return view('settings::index',compact('settings')); } - public function update(Request $request){ $data=$request->validate(['settings'=>['array'],'settings.*'=>['nullable','string','max:5000']]); foreach($data['settings']??[] as $id=>$value){ Setting::whereKey($id)->update(['value'=>$value]); } return back()->with('status','Settings saved.'); } + +use App\Http\Controllers\Controller; +use Illuminate\Http\Request; +use Modules\Settings\Models\Setting; + +final class SettingController extends Controller +{ + public function index() + { + $settings = Setting::query()->orderBy('group')->orderBy('key')->get(); + + return view('settings::index', compact('settings')); + } + + public function update(Request $request) + { + $data = $request->validate(['settings' => ['array'], 'settings.*' => ['nullable', 'string', 'max:5000']]); + foreach ($data['settings'] ?? [] as $id => $value) { + Setting::whereKey($id)->update(['value' => $value]); + } + +return back()->with('status', 'Settings saved.'); + } } diff --git a/Modules/Settings/Models/Setting.php b/Modules/Settings/Models/Setting.php index 53c9cc3..79c0ed6 100644 --- a/Modules/Settings/Models/Setting.php +++ b/Modules/Settings/Models/Setting.php @@ -6,6 +6,10 @@ use Illuminate\Database\Eloquent\Model; final class Setting extends Model { - protected $fillable = ['group','key','value','type','is_public']; - protected function casts(): array { return ['is_public'=>'boolean']; } + protected $fillable = ['group', 'key', 'value', 'type', 'is_public']; + + protected function casts(): array + { + return ['is_public' => 'boolean']; + } } diff --git a/Modules/Settings/Routes/web.php b/Modules/Settings/Routes/web.php index fe860fd..3f28a23 100644 --- a/Modules/Settings/Routes/web.php +++ b/Modules/Settings/Routes/web.php @@ -1,3 +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'); }); + +use Illuminate\Support\Facades\Route; +use Modules\Settings\Http\Controllers\SettingController; + +Route::middleware(['auth', 'verified'])->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/Modules/Settings/Services/SettingsRepository.php b/Modules/Settings/Services/SettingsRepository.php index c31f691..cd520cc 100644 --- a/Modules/Settings/Services/SettingsRepository.php +++ b/Modules/Settings/Services/SettingsRepository.php @@ -8,10 +8,15 @@ use Modules\Settings\Models\Setting; final class SettingsRepository { public function get(string $key, mixed $default = null, string $group = 'general'): mixed - { return Cache::rememberForever("settings.$group.$key", fn () => Setting::query()->where(compact('group','key'))->value('value') ?? $default); } + { + return Cache::rememberForever("settings.$group.$key", fn () => Setting::query()->where(compact('group', 'key'))->value('value') ?? $default); + } + 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'=>is_scalar($value)||$value===null?(string)$value:json_encode($value, JSON_THROW_ON_ERROR),'type'=>$type,'is_public'=>$public]); - Cache::forget("settings.$group.$key"); return $setting; + $setting = Setting::query()->updateOrCreate(compact('group', 'key'), ['value' => is_scalar($value) || $value === null ? (string) $value : json_encode($value, JSON_THROW_ON_ERROR), 'type' => $type, 'is_public' => $public]); + Cache::forget("settings.$group.$key"); + + return $setting; } } diff --git a/Modules/Settings/Support/ModuleInfo.php b/Modules/Settings/Support/ModuleInfo.php index 212eb3b..6c5d3ee 100644 --- a/Modules/Settings/Support/ModuleInfo.php +++ b/Modules/Settings/Support/ModuleInfo.php @@ -4,6 +4,13 @@ namespace Modules\Settings\Support; final class ModuleInfo { - public static function name(): string { return 'Settings'; } - public static function nameLower(): string { return 'settings'; } + public static function name(): string + { + return 'Settings'; + } + + public static function nameLower(): string + { + return 'settings'; + } } diff --git a/Modules/Sitemap/Config/config.php b/Modules/Sitemap/Config/config.php index 1fcb4d5..02edfd4 100644 --- a/Modules/Sitemap/Config/config.php +++ b/Modules/Sitemap/Config/config.php @@ -1,2 +1,3 @@ 'Sitemap']; diff --git a/Modules/Sitemap/Http/Controllers/SitemapController.php b/Modules/Sitemap/Http/Controllers/SitemapController.php index 004ce05..3486a49 100644 --- a/Modules/Sitemap/Http/Controllers/SitemapController.php +++ b/Modules/Sitemap/Http/Controllers/SitemapController.php @@ -1,3 +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'); } } + +namespace Modules\Sitemap\Http\Controllers; + +use App\Http\Controllers\Controller; +use Illuminate\Http\Response; +use Modules\Blog\Models\Blog; +use Modules\News\Models\News; +use Modules\Pages\Models\Page; + +final class SitemapController extends Controller +{ + public function index(): Response + { + $urls = collect(); + $urls->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/Modules/Sitemap/Providers/RouteServiceProvider.php b/Modules/Sitemap/Providers/RouteServiceProvider.php index ad8dfc3..d3894cc 100644 --- a/Modules/Sitemap/Providers/RouteServiceProvider.php +++ b/Modules/Sitemap/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('Sitemap', 'Config/config.php'), 'sitemap'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Sitemap', 'Database/Migrations')); diff --git a/Modules/Sitemap/Routes/web.php b/Modules/Sitemap/Routes/web.php index da78f26..e6ebc68 100644 --- a/Modules/Sitemap/Routes/web.php +++ b/Modules/Sitemap/Routes/web.php @@ -1,2 +1,6 @@ name('sitemap.index'); + +use Illuminate\Support\Facades\Route; +use Modules\Sitemap\Http\Controllers\SitemapController; + +Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('sitemap.index'); diff --git a/Modules/Sitemap/Tests/Feature/ModuleBootTest.php b/Modules/Sitemap/Tests/Feature/ModuleBootTest.php index aebeb40..4b1361f 100644 --- a/Modules/Sitemap/Tests/Feature/ModuleBootTest.php +++ b/Modules/Sitemap/Tests/Feature/ModuleBootTest.php @@ -1,6 +1,9 @@ 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]); } + if ($primary) { + $this->sites()->updateExistingPivot($this->sites()->pluck('sites.id'), ['is_primary' => false]); + } $this->sites()->syncWithoutDetaching([$id => ['is_primary' => $primary]]); } } diff --git a/Modules/Sites/Config/config.php b/Modules/Sites/Config/config.php index 1b7cee1..4941723 100644 --- a/Modules/Sites/Config/config.php +++ b/Modules/Sites/Config/config.php @@ -1,11 +1,16 @@ true, 'session_key' => 'platform_site_id', 'central_domains' => array_values(array_filter(explode(',', (string) env('CENTRAL_DOMAINS', '')))), 'content_types' => [ - 'pages' => Modules\Pages\Models\Page::class, - 'blog' => Modules\Blog\Models\Blog::class, - 'news' => Modules\News\Models\News::class, + 'pages' => Page::class, + 'blog' => Blog::class, + 'news' => News::class, ], ]; diff --git a/Modules/Sites/Contracts/SiteAwareModule.php b/Modules/Sites/Contracts/SiteAwareModule.php index 3566063..61f13f5 100644 --- a/Modules/Sites/Contracts/SiteAwareModule.php +++ b/Modules/Sites/Contracts/SiteAwareModule.php @@ -1,7 +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(); + $table->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(); + $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']); + $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']); + $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']); + $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'); + Schema::dropIfExists('site_user'); + Schema::dropIfExists('site_content'); + Schema::dropIfExists('site_settings'); + Schema::dropIfExists('site_domains'); + Schema::dropIfExists('sites'); } }; diff --git a/Modules/Sites/Database/Seeders/SiteSeeder.php b/Modules/Sites/Database/Seeders/SiteSeeder.php index 1908004..905f213 100644 --- a/Modules/Sites/Database/Seeders/SiteSeeder.php +++ b/Modules/Sites/Database/Seeders/SiteSeeder.php @@ -1,13 +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]); + $site = Site::query()->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/Modules/Sites/Http/Controllers/DomainController.php b/Modules/Sites/Http/Controllers/DomainController.php index 155a886..90e2dca 100644 --- a/Modules/Sites/Http/Controllers/DomainController.php +++ b/Modules/Sites/Http/Controllers/DomainController.php @@ -1,19 +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.'); + $data = $request->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.'); } - 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/Modules/Sites/Http/Controllers/SiteController.php b/Modules/Sites/Http/Controllers/SiteController.php index 7dc537c..3e719f9 100644 --- a/Modules/Sites/Http/Controllers/SiteController.php +++ b/Modules/Sites/Http/Controllers/SiteController.php @@ -1,5 +1,7 @@ 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 index(): View + { + return view('sites::admin.sites.index', ['sites' => 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.'); + 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 + + public function destroy(Site $site): RedirectResponse { - 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')]; + 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/Modules/Sites/Http/Middleware/ResolveSite.php b/Modules/Sites/Http/Middleware/ResolveSite.php index 780ae24..fc3207d 100644 --- a/Modules/Sites/Http/Middleware/ResolveSite.php +++ b/Modules/Sites/Http/Middleware/ResolveSite.php @@ -1,12 +1,16 @@ sites->resolve($request); @@ -14,6 +18,7 @@ final class ResolveSite 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/Modules/Sites/Models/Site.php b/Modules/Sites/Models/Site.php index d9bedd2..a9c2689 100644 --- a/Modules/Sites/Models/Site.php +++ b/Modules/Sites/Models/Site.php @@ -1,19 +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'); } + protected $fillable = ['name', 'key', 'status', 'theme', 'default_locale', 'timezone', 'is_default']; + + protected function casts(): array + { + return ['is_default' => '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/Modules/Sites/Models/SiteContent.php b/Modules/Sites/Models/SiteContent.php index e142b2b..c359fbb 100644 --- a/Modules/Sites/Models/SiteContent.php +++ b/Modules/Sites/Models/SiteContent.php @@ -1,13 +1,29 @@ 'boolean']; } - public function site(): BelongsTo { return $this->belongsTo(Site::class); } - public function content(): MorphTo { return $this->morphTo(__FUNCTION__, 'content_type', 'content_id'); } + + protected $fillable = ['site_id', 'content_type', 'content_id', 'is_primary']; + + protected function casts(): array + { + return ['is_primary' => '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/Modules/Sites/Models/SiteDomain.php b/Modules/Sites/Models/SiteDomain.php index 1011ea4..b92e2cf 100644 --- a/Modules/Sites/Models/SiteDomain.php +++ b/Modules/Sites/Models/SiteDomain.php @@ -1,10 +1,21 @@ 'boolean','redirect_to_primary'=>'boolean','force_https'=>'boolean','is_active'=>'boolean']; } - public function site(): BelongsTo { return $this->belongsTo(Site::class); } + protected $fillable = ['site_id', 'domain', 'is_primary', 'redirect_to_primary', 'force_https', 'is_active']; + + protected function casts(): array + { + return ['is_primary' => 'boolean', 'redirect_to_primary' => 'boolean', 'force_https' => 'boolean', 'is_active' => 'boolean']; + } + + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } } diff --git a/Modules/Sites/Models/SiteSetting.php b/Modules/Sites/Models/SiteSetting.php index bf1b0b4..249314a 100644 --- a/Modules/Sites/Models/SiteSetting.php +++ b/Modules/Sites/Models/SiteSetting.php @@ -1,11 +1,19 @@ belongsTo(Site::class); } + protected $fillable = ['site_id', 'group', 'key', 'value', 'type']; + + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } + public function typedValue(): mixed { return match ($this->type) { diff --git a/Modules/Sites/Providers/RouteServiceProvider.php b/Modules/Sites/Providers/RouteServiceProvider.php index 2b6e293..f4820fa 100644 --- a/Modules/Sites/Providers/RouteServiceProvider.php +++ b/Modules/Sites/Providers/RouteServiceProvider.php @@ -1,8 +1,15 @@ group(module_path('Sites', 'Routes/web.php')); } + public function boot(): void + { + parent::boot(); + Route::middleware('web')->group(module_path('Sites', 'Routes/web.php')); + } } diff --git a/Modules/Sites/Providers/SitesServiceProvider.php b/Modules/Sites/Providers/SitesServiceProvider.php index 5153092..fbfd18f 100644 --- a/Modules/Sites/Providers/SitesServiceProvider.php +++ b/Modules/Sites/Providers/SitesServiceProvider.php @@ -1,7 +1,10 @@ app->singleton(SiteManager::class); $this->app->register(RouteServiceProvider::class); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Sites', 'Database/Migrations')); diff --git a/Modules/Sites/Routes/web.php b/Modules/Sites/Routes/web.php index d01b09f..78ebd55 100644 --- a/Modules/Sites/Routes/web.php +++ b/Modules/Sites/Routes/web.php @@ -1,10 +1,12 @@ prefix('administration')->name('administration.')->group(function (): void { - Route::post('sites/{site}/switch', [SiteController::class,'switch'])->name('sites.switch'); + +Route::middleware(['auth', 'verified'])->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'); + 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/Modules/Sites/Tests/Feature/SitesModuleBootTest.php b/Modules/Sites/Tests/Feature/SitesModuleBootTest.php index d31be8a..94916ed 100644 --- a/Modules/Sites/Tests/Feature/SitesModuleBootTest.php +++ b/Modules/Sites/Tests/Feature/SitesModuleBootTest.php @@ -1,6 +1,9 @@ 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; } @@ -55,6 +58,7 @@ final class CreateThemeCommand extends Command $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/Modules/Stylist/Console/FindDuplicateViewsCommand.php b/Modules/Stylist/Console/FindDuplicateViewsCommand.php index 6fd1093..2304ca1 100644 --- a/Modules/Stylist/Console/FindDuplicateViewsCommand.php +++ b/Modules/Stylist/Console/FindDuplicateViewsCommand.php @@ -8,6 +8,7 @@ use Modules\Stylist\Diagnostics\ThemeInspector; final class FindDuplicateViewsCommand extends Command { protected $signature = 'stylist:duplicates'; + protected $description = 'Find Blade task paths owned by more than one theme.'; public function __construct(private readonly ThemeInspector $inspector) @@ -20,6 +21,7 @@ final class FindDuplicateViewsCommand extends Command $duplicates = $this->inspector->duplicateViews(); if ($duplicates === []) { $this->info('No duplicate theme views found.'); + return self::SUCCESS; } @@ -32,6 +34,7 @@ final class FindDuplicateViewsCommand extends Command $this->table(['View', 'Theme', 'Path'], $rows); $this->error(count($duplicates).' duplicate view task(s) found.'); + return self::FAILURE; } } diff --git a/Modules/Stylist/Console/InspectThemesCommand.php b/Modules/Stylist/Console/InspectThemesCommand.php index 6441970..0764a1f 100644 --- a/Modules/Stylist/Console/InspectThemesCommand.php +++ b/Modules/Stylist/Console/InspectThemesCommand.php @@ -8,6 +8,7 @@ use Modules\Stylist\Diagnostics\ThemeInspector; final class InspectThemesCommand extends Command { protected $signature = 'stylist:inspect'; + protected $description = 'Display installed themes, inheritance, entry views, and view counts.'; public function __construct(private readonly ThemeInspector $inspector) diff --git a/Modules/Stylist/Console/PublishAssetsCommand.php b/Modules/Stylist/Console/PublishAssetsCommand.php index 24f11f1..ab661fa 100644 --- a/Modules/Stylist/Console/PublishAssetsCommand.php +++ b/Modules/Stylist/Console/PublishAssetsCommand.php @@ -7,6 +7,7 @@ use Illuminate\Console\Command; final class PublishAssetsCommand extends Command { protected $signature = 'stylist:publish-assets {--force : Overwrite the published configuration}'; + protected $description = 'Publish the Stylist configuration and ensure resources/themes exists.'; public function handle(): int diff --git a/Modules/Stylist/Console/ThemeDoctorCommand.php b/Modules/Stylist/Console/ThemeDoctorCommand.php index 393c465..d59cb3d 100644 --- a/Modules/Stylist/Console/ThemeDoctorCommand.php +++ b/Modules/Stylist/Console/ThemeDoctorCommand.php @@ -8,6 +8,7 @@ use Modules\Stylist\Diagnostics\ThemeInspector; final class ThemeDoctorCommand extends Command { protected $signature = 'stylist:doctor'; + protected $description = 'Diagnose manifests, inheritance, duplicate views, and theme configuration.'; public function __construct(private readonly ThemeInspector $inspector) @@ -20,6 +21,7 @@ final class ThemeDoctorCommand extends Command $issues = $this->inspector->issues(); if ($issues === []) { $this->info('Stylist is healthy. No theme issues found.'); + return self::SUCCESS; } diff --git a/Modules/Stylist/Console/ValidateThemeCommand.php b/Modules/Stylist/Console/ValidateThemeCommand.php index 5feed39..5e21f93 100644 --- a/Modules/Stylist/Console/ValidateThemeCommand.php +++ b/Modules/Stylist/Console/ValidateThemeCommand.php @@ -37,6 +37,7 @@ final class ValidateThemeCommand extends Command if ($issues === []) { $this->info('Theme validation passed with no issues.'); + return self::SUCCESS; } @@ -56,6 +57,7 @@ final class ValidateThemeCommand extends Command return $errors > 0 ? self::FAILURE : self::SUCCESS; } catch (\Throwable $exception) { $this->error($exception->getMessage()); + return self::FAILURE; } finally { if ($temporary !== null) { @@ -75,7 +77,7 @@ final class ValidateThemeCommand extends Command $temporary = storage_path('app/stylist/validate-'.bin2hex(random_bytes(8))); $this->files->ensureDirectoryExists($temporary); - $zip = new ZipArchive(); + $zip = new ZipArchive; if ($zip->open($input) !== true) { throw new RuntimeException('The theme ZIP could not be opened.'); } diff --git a/Modules/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php b/Modules/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php index 5b0c2d4..57b6955 100644 --- a/Modules/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php +++ b/Modules/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php @@ -4,7 +4,21 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { - public function up(): void { Schema::create('stylist_theme_settings', function (Blueprint $table) { $table->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'); } +return new class extends Migration +{ + public function up(): void + { + Schema::create('stylist_theme_settings', function (Blueprint $table) { + $table->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/Modules/Stylist/Diagnostics/ThemeInspector.php b/Modules/Stylist/Diagnostics/ThemeInspector.php index e26c6ec..371d543 100644 --- a/Modules/Stylist/Diagnostics/ThemeInspector.php +++ b/Modules/Stylist/Diagnostics/ThemeInspector.php @@ -120,6 +120,7 @@ final class ThemeInspector } ksort($views); + return $views; } diff --git a/Modules/Stylist/Helpers/helpers.php b/Modules/Stylist/Helpers/helpers.php index a6ea4ec..9260a86 100644 --- a/Modules/Stylist/Helpers/helpers.php +++ b/Modules/Stylist/Helpers/helpers.php @@ -1,6 +1,7 @@ switcher(); } diff --git a/Modules/Stylist/Html/ThemeHtmlBuilder.php b/Modules/Stylist/Html/ThemeHtmlBuilder.php index 724c769..b7b3c16 100644 --- a/Modules/Stylist/Html/ThemeHtmlBuilder.php +++ b/Modules/Stylist/Html/ThemeHtmlBuilder.php @@ -13,14 +13,20 @@ final class ThemeHtmlBuilder public function stylesheet(?string $path = null): HtmlString { $path ??= $this->stylist->current()->stylesheet(); - if (! $this->stylist->current()->has($path)) return new HtmlString(''); + 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(''); + if (! $this->stylist->current()->has($path)) { + return new HtmlString(''); + } + return new HtmlString(''); } @@ -32,11 +38,15 @@ final class ThemeHtmlBuilder foreach ($theme->appearances() as $name => $definition) { $vars = (array) data_get($definition, 'variables', []); - if ($vars) $rules[] = ':root[data-theme="'.$this->selector($name).'"]{'.$this->declarations($vars).'}'; + 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 ($vars) { + $rules[] = ':root[data-accent="'.$this->selector($name).'"]{'.$this->declarations($vars).'}'; + } } if ($theme->appearances() === [] && $theme->accents() === []) { diff --git a/Modules/Stylist/Html/ThemeSeoBuilder.php b/Modules/Stylist/Html/ThemeSeoBuilder.php index 6e7d4ec..28d5840 100644 --- a/Modules/Stylist/Html/ThemeSeoBuilder.php +++ b/Modules/Stylist/Html/ThemeSeoBuilder.php @@ -9,15 +9,36 @@ use Modules\Stylist\Theme\UrlGenerator; final class ThemeSeoBuilder { public function __construct(private readonly Stylist $stylist, private readonly UrlGenerator $urls) {} - public function render(array $overrides=[]): HtmlString + + public function render(array $overrides = []): HtmlString { - $theme=$this->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)); + $theme = $this->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/Modules/Stylist/Http/Controllers/ThemeManagementController.php b/Modules/Stylist/Http/Controllers/ThemeManagementController.php index 0888265..8918072 100644 --- a/Modules/Stylist/Http/Controllers/ThemeManagementController.php +++ b/Modules/Stylist/Http/Controllers/ThemeManagementController.php @@ -22,29 +22,42 @@ final class ThemeManagementController extends Controller $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']]); + $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); + $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); + $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); + $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/Modules/Stylist/Models/ThemeSetting.php b/Modules/Stylist/Models/ThemeSetting.php index a50e2f7..fb01e6f 100644 --- a/Modules/Stylist/Models/ThemeSetting.php +++ b/Modules/Stylist/Models/ThemeSetting.php @@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Model; final class ThemeSetting extends Model { protected $table = 'stylist_theme_settings'; + protected $fillable = ['theme_key', 'variant', 'settings']; + protected $casts = ['settings' => 'array']; } diff --git a/Modules/Stylist/Providers/StylistServiceProvider.php b/Modules/Stylist/Providers/StylistServiceProvider.php index 7d3ecad..c4ccfea 100644 --- a/Modules/Stylist/Providers/StylistServiceProvider.php +++ b/Modules/Stylist/Providers/StylistServiceProvider.php @@ -19,6 +19,7 @@ use Modules\Stylist\Theme\Stylist; final class StylistServiceProvider extends ServiceProvider { protected string $moduleName = 'Stylist'; + protected string $moduleNameLower = 'stylist'; public function register(): void @@ -135,7 +136,6 @@ final class StylistServiceProvider extends ServiceProvider } } - private function normalisePath(string $path): string { return rtrim( diff --git a/Modules/Stylist/Services/ThemeAccessibilityValidator.php b/Modules/Stylist/Services/ThemeAccessibilityValidator.php index d95ca38..70b68bb 100644 --- a/Modules/Stylist/Services/ThemeAccessibilityValidator.php +++ b/Modules/Stylist/Services/ThemeAccessibilityValidator.php @@ -26,6 +26,7 @@ final class ThemeAccessibilityValidator $issues[] = ['severity' => 'warning', 'message' => count($matches[0]).' button(s) may lack an accessible name.', 'file' => $relative]; } } + return $issues; } } diff --git a/Modules/Stylist/Services/ThemeOptimizer.php b/Modules/Stylist/Services/ThemeOptimizer.php index db52760..def6927 100644 --- a/Modules/Stylist/Services/ThemeOptimizer.php +++ b/Modules/Stylist/Services/ThemeOptimizer.php @@ -14,9 +14,13 @@ final class ThemeOptimizer $result = ['css' => 0, 'js' => 0, 'images' => 0]; foreach (['css' => 'css', 'js' => 'js'] as $type => $folder) { $directory = $theme->path().'/'.$folder; - if (! $this->files->isDirectory($directory)) continue; + 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; + 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; @@ -29,15 +33,25 @@ final class ThemeOptimizer if (function_exists('imagewebp')) { foreach (['images', 'icons'] as $folder) { $directory = $theme->path().'/'.$folder; - if (! $this->files->isDirectory($directory)) continue; + 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; + 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']++; } + if ($image) { + imagepalettetotruecolor($image); + imagewebp($image, $file->getPath().'/'.$file->getBasename('.'.$ext).'.webp', 82); + imagedestroy($image); + $result['images']++; + } } } } + return $result; } } diff --git a/Modules/Stylist/Services/ThemePackageManager.php b/Modules/Stylist/Services/ThemePackageManager.php index 01e4deb..0319b03 100644 --- a/Modules/Stylist/Services/ThemePackageManager.php +++ b/Modules/Stylist/Services/ThemePackageManager.php @@ -15,28 +15,46 @@ final class ThemePackageManager public function install(UploadedFile $upload): Theme { - if (strtolower($upload->getClientOriginalExtension()) !== 'zip') throw new RuntimeException('Only ZIP theme packages may be installed.'); + if (strtolower($upload->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 = new ZipArchive; + if ($zip->open($upload->getRealPath()) !== true) { + throw new RuntimeException('The uploaded ZIP could not be opened.'); } - $zip->extractTo($temporary); $zip->close(); + 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.'); } + 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.'); + 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(' ')); + 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); + 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()); } @@ -44,17 +62,30 @@ final class ThemePackageManager { $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(); + 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); + $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; + $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/Modules/Stylist/Services/ThemeSettings.php b/Modules/Stylist/Services/ThemeSettings.php index cccf8cd..0a1f253 100644 --- a/Modules/Stylist/Services/ThemeSettings.php +++ b/Modules/Stylist/Services/ThemeSettings.php @@ -9,6 +9,7 @@ use Modules\Stylist\Theme\Theme; final class ThemeSettings { private const APPEARANCE_KEY = '__stylist_appearance'; + private const ACCENT_KEY = '__stylist_accent'; public function get(Theme $theme): array @@ -24,6 +25,7 @@ final class ThemeSettings 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'); } @@ -31,8 +33,11 @@ final class ThemeSettings { $stored = (array) ($this->stored($theme)?->settings ?? []); $allowed = array_keys($theme->appearances()); - if ($allowed === []) return ''; + if ($allowed === []) { + return ''; + } $appearance = (string) ($stored[self::APPEARANCE_KEY] ?? $theme->defaultAppearance()); + return in_array($appearance, $allowed, true) ? $appearance : (string) array_key_first($theme->appearances()); } @@ -40,8 +45,11 @@ final class ThemeSettings { $stored = (array) ($this->stored($theme)?->settings ?? []); $allowed = array_keys($theme->accents()); - if ($allowed === []) return ''; + if ($allowed === []) { + return ''; + } $accent = (string) ($stored[self::ACCENT_KEY] ?? $theme->defaultAccent()); + return in_array($accent, $allowed, true) ? $accent : (string) array_key_first($theme->accents()); } @@ -53,18 +61,24 @@ final class ThemeSettings $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()); + 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()); + 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()); + if ($theme->variants() && ! array_key_exists($variant, $theme->variants())) { + $variant = array_key_first($theme->variants()); + } return ThemeSetting::query()->updateOrCreate( ['theme_key' => $theme->key()], @@ -74,7 +88,10 @@ final class ThemeSettings private function stored(Theme $theme): ?ThemeSetting { - try { return ThemeSetting::query()->where('theme_key', $theme->key())->first(); } - catch (QueryException) { return null; } + try { + return ThemeSetting::query()->where('theme_key', $theme->key())->first(); + } catch (QueryException) { + return null; + } } } diff --git a/Modules/Stylist/Tests/Feature/ThemeManifestTest.php b/Modules/Stylist/Tests/Feature/ThemeManifestTest.php index 011738e..11d681e 100644 --- a/Modules/Stylist/Tests/Feature/ThemeManifestTest.php +++ b/Modules/Stylist/Tests/Feature/ThemeManifestTest.php @@ -54,6 +54,7 @@ final class ThemeManifestTest extends TestCase $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'; @@ -153,5 +154,4 @@ final class ThemeManifestTest extends TestCase $this->assertSame(['anything' => true], $theme->meta()['future_builder_metadata']); } - } diff --git a/Modules/Stylist/Tests/Feature/ThemePackageRoundTripTest.php b/Modules/Stylist/Tests/Feature/ThemePackageRoundTripTest.php index 5fa902e..633b3dc 100644 --- a/Modules/Stylist/Tests/Feature/ThemePackageRoundTripTest.php +++ b/Modules/Stylist/Tests/Feature/ThemePackageRoundTripTest.php @@ -12,6 +12,7 @@ use ZipArchive; final class ThemePackageRoundTripTest extends TestCase { private string $themesPath; + private string $workspace; protected function setUp(): void @@ -58,7 +59,7 @@ final class ThemePackageRoundTripTest extends TestCase file_put_contents($source.'/views/home.blade.php', '
Design Gallery works
'); $zipPath = $this->workspace.'/design-gallery.zip'; - $zip = new ZipArchive(); + $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()); diff --git a/Modules/Stylist/Theme/ManifestValidator.php b/Modules/Stylist/Theme/ManifestValidator.php index 3612420..3e33821 100644 --- a/Modules/Stylist/Theme/ManifestValidator.php +++ b/Modules/Stylist/Theme/ManifestValidator.php @@ -104,8 +104,8 @@ final class ManifestValidator * Compact lists such as ["light", "dark"] are accepted and converted to * keyed empty definitions. Object definitions remain unchanged. * - * @param array $value - * @param array $errors + * @param array $value + * @param array $errors * @return array> */ private function normaliseDefinitions(array $value, string $field, array &$errors, bool $allowCompact = true): array @@ -125,10 +125,12 @@ final class ManifestValidator 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; } @@ -136,6 +138,7 @@ final class ManifestValidator 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; @@ -145,8 +148,8 @@ final class ManifestValidator } /** - * @param array $value - * @param array $errors + * @param array $value + * @param array $errors * @return array */ private function normaliseCapabilities(array $value, array &$errors): array @@ -156,6 +159,7 @@ final class ManifestValidator 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; } diff --git a/Modules/Stylist/Theme/Stylist.php b/Modules/Stylist/Theme/Stylist.php index 608bb93..dbc7114 100644 --- a/Modules/Stylist/Theme/Stylist.php +++ b/Modules/Stylist/Theme/Stylist.php @@ -13,10 +13,13 @@ final class Stylist public function current(): Theme { $requested = $this->resolver->resolve(); - try { return $this->loader->find($requested); } - catch (ThemeNotFoundException) { + try { + return $this->loader->find($requested); + } catch (ThemeNotFoundException) { $fallback = (string) config('stylist.default_theme', 'default'); - if ($requested !== $fallback) return $this->loader->find($fallback); + if ($requested !== $fallback) { + return $this->loader->find($fallback); + } throw ThemeNotFoundException::forKey($requested); } } @@ -44,21 +47,45 @@ final class Stylist ); } - 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(); } + 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 []; + 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) {} + try { + $parent = $this->inheritedVariables($this->loader->find($theme->parent()), $seen); + } catch (ThemeNotFoundException) { + } } + return array_replace($parent, $theme->variables()); } } diff --git a/Modules/Stylist/Theme/Theme.php b/Modules/Stylist/Theme/Theme.php index c2d1205..f78f463 100644 --- a/Modules/Stylist/Theme/Theme.php +++ b/Modules/Stylist/Theme/Theme.php @@ -14,33 +14,143 @@ final class Theme private readonly array $meta = [], ) {} - public function key(): string { return $this->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 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, '/'); } + public function key(): string + { + return $this->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 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; } + public function assetsPath(): string + { + return 'stylist/theme-assets/'.$this->key; + } } diff --git a/Modules/Stylist/Theme/UrlGenerator.php b/Modules/Stylist/Theme/UrlGenerator.php index e762710..77d08f8 100644 --- a/Modules/Stylist/Theme/UrlGenerator.php +++ b/Modules/Stylist/Theme/UrlGenerator.php @@ -5,10 +5,16 @@ namespace Modules\Stylist\Theme; final class UrlGenerator { public function __construct(private readonly Stylist $stylist) {} - public function asset(string $path): string { return $this->forTheme($this->stylist->current(), $path); } + + public function asset(string $path): string + { + return $this->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/Modules/Tags/Config/config.php b/Modules/Tags/Config/config.php index 75e8829..4f63319 100644 --- a/Modules/Tags/Config/config.php +++ b/Modules/Tags/Config/config.php @@ -1,2 +1,3 @@ 'Tags']; diff --git a/Modules/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php b/Modules/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php index b4fc39a..8a969e7 100644 --- a/Modules/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php +++ b/Modules/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php @@ -1,3 +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'); } }; + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('tags', function (Blueprint $table): void { + $table->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/Modules/Tags/Http/Controllers/TagController.php b/Modules/Tags/Http/Controllers/TagController.php index a32d431..08e0a08 100644 --- a/Modules/Tags/Http/Controllers/TagController.php +++ b/Modules/Tags/Http/Controllers/TagController.php @@ -1,4 +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.'); } } + +use App\Http\Controllers\Controller; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\View\View; +use Modules\Tags\Models\Tag; + +final class TagController extends Controller +{ + public function index(): View + { + return view('tags::admin.index', ['records' => 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/Modules/Tags/Models/Tag.php b/Modules/Tags/Models/Tag.php index 3077f09..da7b39c 100644 --- a/Modules/Tags/Models/Tag.php +++ b/Modules/Tags/Models/Tag.php @@ -1,3 +1,15 @@ 'integer']; } } + +namespace Modules\Tags\Models; + +use Illuminate\Database\Eloquent\Model; + +final class Tag extends Model +{ + protected $fillable = ['name', 'slug', 'description', 'usage_count']; + + protected function casts(): array + { + return ['usage_count' => 'integer']; + } +} diff --git a/Modules/Tags/Providers/RouteServiceProvider.php b/Modules/Tags/Providers/RouteServiceProvider.php index 4d09871..3605636 100644 --- a/Modules/Tags/Providers/RouteServiceProvider.php +++ b/Modules/Tags/Providers/RouteServiceProvider.php @@ -1,7 +1,10 @@ app->register(RouteServiceProvider::class); $this->mergeConfigFrom(module_path('Tags', 'Config/config.php'), 'tags'); } + public function boot(): void { $this->loadMigrationsFrom(module_path('Tags', 'Database/Migrations')); diff --git a/Modules/Tags/Routes/web.php b/Modules/Tags/Routes/web.php index 3face93..ca502b4 100644 --- a/Modules/Tags/Routes/web.php +++ b/Modules/Tags/Routes/web.php @@ -1,3 +1,8 @@ prefix('administration/tags')->name('administration.tags.')->group(function (): void { Route::resource('/', TagController::class)->parameters([''=>'tag'])->except('show'); }); + +use Illuminate\Support\Facades\Route; +use Modules\Tags\Http\Controllers\TagController; + +Route::middleware(['auth', 'verified'])->prefix('administration/tags')->name('administration.tags.')->group(function (): void { + Route::resource('/', TagController::class)->parameters(['' => 'tag'])->except('show'); +}); diff --git a/Modules/Tags/Tests/Feature/ModuleBootTest.php b/Modules/Tags/Tests/Feature/ModuleBootTest.php index 2d9807c..1bc2dd9 100644 --- a/Modules/Tags/Tests/Feature/ModuleBootTest.php +++ b/Modules/Tags/Tests/Feature/ModuleBootTest.php @@ -1,6 +1,9 @@ id(); +return new class extends Migration +{ + public function up(): void + { + Schema::create('team_members', function (Blueprint $table): void { + $table->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'); } + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('team_members'); + } }; diff --git a/Modules/Team/Http/Controllers/TeamMemberController.php b/Modules/Team/Http/Controllers/TeamMemberController.php index 2de2eb8..bd62536 100644 --- a/Modules/Team/Http/Controllers/TeamMemberController.php +++ b/Modules/Team/Http/Controllers/TeamMemberController.php @@ -10,20 +10,53 @@ use Modules\Team\Models\TeamMember; final class TeamMemberController extends Controller { - public function index(): View { return view('team::admin.index', ['records' => 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'], + public function index(): View + { + return view('team::admin.index', ['records' => 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('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'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + TeamMember::create($data); + + return redirect()->route('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('team.index')->with('status', 'TeamMember updated.'); } - public function destroy(TeamMember $teamMember): RedirectResponse { $teamMember->delete(); return back()->with('status', 'TeamMember deleted.'); } + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + $teamMember->update($data); + + return redirect()->route('team.index')->with('status', 'TeamMember updated.'); + } + + public function destroy(TeamMember $teamMember): RedirectResponse + { + $teamMember->delete(); + + return back()->with('status', 'TeamMember deleted.'); + } } diff --git a/Modules/Team/Models/TeamMember.php b/Modules/Team/Models/TeamMember.php index fd7b518..1ada6eb 100644 --- a/Modules/Team/Models/TeamMember.php +++ b/Modules/Team/Models/TeamMember.php @@ -9,12 +9,16 @@ use Illuminate\Database\Eloquent\SoftDeletes; final class TeamMember extends Model { use HasFactory, SoftDeletes; - protected $table = 'team_members'; - protected $fillable = ['name', 'job_title', 'biography', 'email', 'sort_order', 'enabled']; - - protected function casts(): array { return [ - 'sort_order' => 'integer', - 'enabled' => 'boolean' - ]; } + protected $table = 'team_members'; + + protected $fillable = ['name', 'job_title', 'biography', 'email', 'sort_order', 'enabled']; + + protected function casts(): array + { + return [ + 'sort_order' => 'integer', + 'enabled' => 'boolean', + ]; + } } diff --git a/Modules/Team/Support/ModuleInfo.php b/Modules/Team/Support/ModuleInfo.php index 3189d8a..4fcd387 100644 --- a/Modules/Team/Support/ModuleInfo.php +++ b/Modules/Team/Support/ModuleInfo.php @@ -2,4 +2,15 @@ namespace Modules\Team\Support; -final class ModuleInfo { public static function name(): string { return 'Team'; } public static function nameLower(): string { return 'team'; } } +final class ModuleInfo +{ + public static function name(): string + { + return 'Team'; + } + + public static function nameLower(): string + { + return 'team'; + } +} diff --git a/Modules/Team/Tests/Feature/ModuleBootTest.php b/Modules/Team/Tests/Feature/ModuleBootTest.php index 0a12a2e..296ba82 100644 --- a/Modules/Team/Tests/Feature/ModuleBootTest.php +++ b/Modules/Team/Tests/Feature/ModuleBootTest.php @@ -2,4 +2,6 @@ use Illuminate\Support\Facades\Route; -it('registers the Team administration route', function (): void { expect(Route::has('administration.team.index'))->toBeTrue(); }); +it('registers the Team administration route', function (): void { + expect(Route::has('administration.team.index'))->toBeTrue(); +}); diff --git a/Modules/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php b/Modules/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php index 43bed44..bebe283 100644 --- a/Modules/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php +++ b/Modules/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php @@ -4,13 +4,24 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { - public function up(): void { Schema::create('testimonials', function (Blueprint $table): void { $table->id(); +return new class extends Migration +{ + public function up(): void + { + Schema::create('testimonials', function (Blueprint $table): void { + $table->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'); } + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('testimonials'); + } }; diff --git a/Modules/Testimonials/Http/Controllers/TestimonialController.php b/Modules/Testimonials/Http/Controllers/TestimonialController.php index b5d44a3..676166e 100644 --- a/Modules/Testimonials/Http/Controllers/TestimonialController.php +++ b/Modules/Testimonials/Http/Controllers/TestimonialController.php @@ -10,18 +10,51 @@ use Modules\Testimonials\Models\Testimonial; final class TestimonialController extends Controller { - public function index(): View { return view('testimonials::admin.index', ['records' => 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'], + public function index(): View + { + return view('testimonials::admin.index', ['records' => 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('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'], + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + Testimonial::create($data); + + return redirect()->route('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('testimonials.index')->with('status', 'Testimonial updated.'); } - public function destroy(Testimonial $testimonial): RedirectResponse { $testimonial->delete(); return back()->with('status', 'Testimonial deleted.'); } + 'enabled' => ['sometimes', 'boolean']]); + $data['enabled'] = $request->boolean('enabled'); + $testimonial->update($data); + + return redirect()->route('testimonials.index')->with('status', 'Testimonial updated.'); + } + + public function destroy(Testimonial $testimonial): RedirectResponse + { + $testimonial->delete(); + + return back()->with('status', 'Testimonial deleted.'); + } } diff --git a/Modules/Testimonials/Models/Testimonial.php b/Modules/Testimonials/Models/Testimonial.php index 642d9a2..590160f 100644 --- a/Modules/Testimonials/Models/Testimonial.php +++ b/Modules/Testimonials/Models/Testimonial.php @@ -9,12 +9,16 @@ use Illuminate\Database\Eloquent\SoftDeletes; final class Testimonial extends Model { use HasFactory, SoftDeletes; - protected $table = 'testimonials'; - protected $fillable = ['name', 'company', 'content', 'rating', 'enabled']; - - protected function casts(): array { return [ - 'rating' => 'integer', - 'enabled' => 'boolean' - ]; } + protected $table = 'testimonials'; + + protected $fillable = ['name', 'company', 'content', 'rating', 'enabled']; + + protected function casts(): array + { + return [ + 'rating' => 'integer', + 'enabled' => 'boolean', + ]; + } } diff --git a/Modules/Testimonials/Support/ModuleInfo.php b/Modules/Testimonials/Support/ModuleInfo.php index 57474b2..afc0468 100644 --- a/Modules/Testimonials/Support/ModuleInfo.php +++ b/Modules/Testimonials/Support/ModuleInfo.php @@ -2,4 +2,15 @@ namespace Modules\Testimonials\Support; -final class ModuleInfo { public static function name(): string { return 'Testimonials'; } public static function nameLower(): string { return 'testimonials'; } } +final class ModuleInfo +{ + public static function name(): string + { + return 'Testimonials'; + } + + public static function nameLower(): string + { + return 'testimonials'; + } +} diff --git a/Modules/Testimonials/Tests/Feature/ModuleBootTest.php b/Modules/Testimonials/Tests/Feature/ModuleBootTest.php index 583e063..3caefc7 100644 --- a/Modules/Testimonials/Tests/Feature/ModuleBootTest.php +++ b/Modules/Testimonials/Tests/Feature/ModuleBootTest.php @@ -2,4 +2,6 @@ use Illuminate\Support\Facades\Route; -it('registers the Testimonials administration route', function (): void { expect(Route::has('administration.testimonials.index'))->toBeTrue(); }); +it('registers the Testimonials administration route', function (): void { + expect(Route::has('administration.testimonials.index'))->toBeTrue(); +}); diff --git a/Modules/Users/Http/Controllers/UserController.php b/Modules/Users/Http/Controllers/UserController.php index 6bf2b99..79191a5 100644 --- a/Modules/Users/Http/Controllers/UserController.php +++ b/Modules/Users/Http/Controllers/UserController.php @@ -10,22 +10,47 @@ use Spatie\Permission\Models\Role; final class UserController extends Controller { - public function index() { return view('stylist-theme-admin::administration.users.index', ['users' => 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 index() + { + return view('stylist-theme-admin::administration.users.index', ['users' => User::with('roles')->latest()->paginate(20)]); } - 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 create() + { + return view('stylist-theme-admin::administration.users.create', ['roles' => Role::orderBy('name')->pluck('name')]); } - 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.'); + + 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/Modules/Users/Routes/web.php b/Modules/Users/Routes/web.php index d8939b0..d4d0dc8 100644 --- a/Modules/Users/Routes/web.php +++ b/Modules/Users/Routes/web.php @@ -1,3 +1,8 @@ prefix('administration/users')->name('administration.users.')->group(function (): void { Route::resource('', UserController::class)->parameters([''=>'user'])->except(['show']); }); + +use Illuminate\Support\Facades\Route; +use Modules\Users\Http\Controllers\UserController; + +Route::middleware(['auth', 'verified'])->prefix('administration/users')->name('administration.users.')->group(function (): void { + Route::resource('', UserController::class)->parameters(['' => 'user'])->except(['show']); +}); diff --git a/Modules/Users/Support/ModuleInfo.php b/Modules/Users/Support/ModuleInfo.php index 8586949..6122d1c 100644 --- a/Modules/Users/Support/ModuleInfo.php +++ b/Modules/Users/Support/ModuleInfo.php @@ -4,6 +4,13 @@ namespace Modules\Users\Support; final class ModuleInfo { - public static function name(): string { return 'Users'; } - public static function nameLower(): string { return 'users'; } + public static function name(): string + { + return 'Users'; + } + + public static function nameLower(): string + { + return 'users'; + } } diff --git a/Modules/Workflow/Concerns/HasWorkflow.php b/Modules/Workflow/Concerns/HasWorkflow.php index 34ab708..d018a87 100644 --- a/Modules/Workflow/Concerns/HasWorkflow.php +++ b/Modules/Workflow/Concerns/HasWorkflow.php @@ -1,4 +1,19 @@ morphOne(WorkflowInstance::class,'subject'); } public function workflowLabel(): string { return (string) ($this->title ?? $this->name ?? class_basename($this).' #'.$this->getKey()); } } + +use Illuminate\Database\Eloquent\Relations\MorphOne; +use Modules\Workflow\Models\WorkflowInstance; + +trait HasWorkflow +{ + public function workflowInstance(): MorphOne + { + return $this->morphOne(WorkflowInstance::class, 'subject'); + } + + public function workflowLabel(): string + { + return (string) ($this->title ?? $this->name ?? class_basename($this).' #'.$this->getKey()); + } +} diff --git a/Modules/Workflow/Config/config.php b/Modules/Workflow/Config/config.php index 33f0c2c..c61a662 100644 --- a/Modules/Workflow/Config/config.php +++ b/Modules/Workflow/Config/config.php @@ -1,2 +1,3 @@ 'Workflow', 'default_due_days' => 3]; diff --git a/Modules/Workflow/Contracts/Workflowable.php b/Modules/Workflow/Contracts/Workflowable.php index 5661475..9d76383 100644 --- a/Modules/Workflow/Contracts/Workflowable.php +++ b/Modules/Workflow/Contracts/Workflowable.php @@ -1,4 +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'); } + +return new class extends Migration +{ + public function up(): void + { + Schema::create('workflow_definitions', function (Blueprint $t) { + $t->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/Modules/Workflow/Http/Controllers/WorkflowController.php b/Modules/Workflow/Http/Controllers/WorkflowController.php index cface5c..62479b1 100644 --- a/Modules/Workflow/Http/Controllers/WorkflowController.php +++ b/Modules/Workflow/Http/Controllers/WorkflowController.php @@ -1,8 +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.'); } + +use App\Http\Controllers\Controller; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Str; +use Illuminate\View\View; +use Modules\Workflow\Models\WorkflowDefinition; +use Modules\Workflow\Models\WorkflowInstance; +use Modules\Workflow\Services\WorkflowManager; + +final class WorkflowController extends Controller +{ + public function index(): View + { + abort_unless(auth()->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/Modules/Workflow/Models/WorkflowDefinition.php b/Modules/Workflow/Models/WorkflowDefinition.php index d7140ee..2316842 100644 --- a/Modules/Workflow/Models/WorkflowDefinition.php +++ b/Modules/Workflow/Models/WorkflowDefinition.php @@ -1,4 +1,26 @@ 'boolean']; } public function stages(): HasMany { return $this->hasMany(WorkflowStage::class)->orderBy('position'); } public function transitions(): HasMany { return $this->hasMany(WorkflowTransition::class); } } + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; + +final class WorkflowDefinition extends Model +{ + protected $fillable = ['name', 'slug', 'description', 'subject_type', 'is_active']; + + protected function casts(): array + { + return ['is_active' => 'boolean']; + } + + public function stages(): HasMany + { + return $this->hasMany(WorkflowStage::class)->orderBy('position'); + } + + public function transitions(): HasMany + { + return $this->hasMany(WorkflowTransition::class); + } +} diff --git a/Modules/Workflow/Models/WorkflowHistory.php b/Modules/Workflow/Models/WorkflowHistory.php index 15f3fae..2b8786f 100644 --- a/Modules/Workflow/Models/WorkflowHistory.php +++ b/Modules/Workflow/Models/WorkflowHistory.php @@ -1,4 +1,17 @@ 'array']; } } + +final class WorkflowHistory extends Model +{ + protected $table = 'workflow_history'; + + protected $fillable = ['workflow_instance_id', 'from_stage_id', 'to_stage_id', 'user_id', 'action', 'comment', 'metadata']; + + protected function casts(): array + { + return ['metadata' => 'array']; + } +} diff --git a/Modules/Workflow/Models/WorkflowInstance.php b/Modules/Workflow/Models/WorkflowInstance.php index 2d05a36..b3d1af4 100644 --- a/Modules/Workflow/Models/WorkflowInstance.php +++ b/Modules/Workflow/Models/WorkflowInstance.php @@ -1,4 +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(); } } + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphTo; + +final class WorkflowInstance extends Model +{ + protected $fillable = ['workflow_definition_id', 'workflow_stage_id', 'subject_type', 'subject_id', 'status', 'started_by', 'assigned_to', 'due_at', 'completed_at']; + + protected function casts(): array + { + return ['due_at' => '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/Modules/Workflow/Models/WorkflowStage.php b/Modules/Workflow/Models/WorkflowStage.php index 530c564..3d8a8ad 100644 --- a/Modules/Workflow/Models/WorkflowStage.php +++ b/Modules/Workflow/Models/WorkflowStage.php @@ -1,4 +1,21 @@ 'boolean','is_terminal'=>'boolean','allows_rejection'=>'boolean']; } public function definition(): BelongsTo { return $this->belongsTo(WorkflowDefinition::class,'workflow_definition_id'); } } + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +final class WorkflowStage extends Model +{ + protected $fillable = ['workflow_definition_id', 'name', 'slug', 'position', 'required_permission', 'is_initial', 'is_terminal', 'allows_rejection', 'due_days']; + + protected function casts(): array + { + return ['is_initial' => 'boolean', 'is_terminal' => 'boolean', 'allows_rejection' => 'boolean']; + } + + public function definition(): BelongsTo + { + return $this->belongsTo(WorkflowDefinition::class, 'workflow_definition_id'); + } +} diff --git a/Modules/Workflow/Models/WorkflowTransition.php b/Modules/Workflow/Models/WorkflowTransition.php index dc0cc37..66bb87f 100644 --- a/Modules/Workflow/Models/WorkflowTransition.php +++ b/Modules/Workflow/Models/WorkflowTransition.php @@ -1,4 +1,15 @@ 'boolean']; } } + +final class WorkflowTransition extends Model +{ + protected $fillable = ['workflow_definition_id', 'from_stage_id', 'to_stage_id', 'name', 'action', 'required_permission', 'requires_comment']; + + protected function casts(): array + { + return ['requires_comment' => 'boolean']; + } +} diff --git a/Modules/Workflow/Providers/RouteServiceProvider.php b/Modules/Workflow/Providers/RouteServiceProvider.php index b8c4fcf..4197f68 100644 --- a/Modules/Workflow/Providers/RouteServiceProvider.php +++ b/Modules/Workflow/Providers/RouteServiceProvider.php @@ -1,5 +1,15 @@ group(module_path('Workflow', 'Routes/web.php')); } } + +final class RouteServiceProvider extends ServiceProvider +{ + public function boot(): void + { + parent::boot(); + Route::middleware('web')->group(module_path('Workflow', 'Routes/web.php')); + } +} diff --git a/Modules/Workflow/Providers/WorkflowServiceProvider.php b/Modules/Workflow/Providers/WorkflowServiceProvider.php index b6d0f2c..ab10b81 100644 --- a/Modules/Workflow/Providers/WorkflowServiceProvider.php +++ b/Modules/Workflow/Providers/WorkflowServiceProvider.php @@ -1,8 +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'); } + +final class WorkflowServiceProvider extends ServiceProvider +{ + public function register(): void + { + $this->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/Modules/Workflow/Routes/web.php b/Modules/Workflow/Routes/web.php index 24773fd..6653370 100644 --- a/Modules/Workflow/Routes/web.php +++ b/Modules/Workflow/Routes/web.php @@ -1,3 +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'); }); + +use Illuminate\Support\Facades\Route; +use Modules\Workflow\Http\Controllers\WorkflowController; + +Route::middleware(['auth', 'verified'])->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/Modules/Workflow/Services/WorkflowManager.php b/Modules/Workflow/Services/WorkflowManager.php index 4033643..7b8dc1b 100644 --- a/Modules/Workflow/Services/WorkflowManager.php +++ b/Modules/Workflow/Services/WorkflowManager.php @@ -1,7 +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=\Modules\Workflow\Models\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']); }); } + +use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; +use Modules\Workflow\Models\WorkflowDefinition; +use Modules\Workflow\Models\WorkflowHistory; +use Modules\Workflow\Models\WorkflowInstance; +use Modules\Workflow\Models\WorkflowStage; +use Modules\Workflow\Models\WorkflowTransition; + +final class WorkflowManager +{ + public function start(Model $subject, WorkflowDefinition $definition, ?Authenticatable $user = null): WorkflowInstance + { + return DB::transaction(function () use ($subject, $definition, $user) { + $stage = $definition->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/Modules/Workflow/Tests/Feature/ModuleBootTest.php b/Modules/Workflow/Tests/Feature/ModuleBootTest.php index 763e90d..e489747 100644 --- a/Modules/Workflow/Tests/Feature/ModuleBootTest.php +++ b/Modules/Workflow/Tests/Feature/ModuleBootTest.php @@ -1,4 +1,14 @@ assertTrue(app()->providerIsLoaded(\Modules\Workflow\Providers\WorkflowServiceProvider::class)); } } + +final class ModuleBootTest extends TestCase +{ + public function test_workflow_module_is_registered(): void + { + $this->assertTrue(app()->providerIsLoaded(WorkflowServiceProvider::class)); + } +} diff --git a/app/Livewire/Settings/Appearance.php b/app/Livewire/Settings/Appearance.php index 417b894..11a0025 100644 --- a/app/Livewire/Settings/Appearance.php +++ b/app/Livewire/Settings/Appearance.php @@ -16,5 +16,4 @@ class Appearance extends Component return view('stylist-theme-common::livewire.settings.appearance') ->layout('stylist-theme-common::components.layouts.app'); } - } diff --git a/app/Livewire/Settings/DeleteUserForm.php b/app/Livewire/Settings/DeleteUserForm.php index c914080..0dcb1c8 100644 --- a/app/Livewire/Settings/DeleteUserForm.php +++ b/app/Livewire/Settings/DeleteUserForm.php @@ -2,9 +2,9 @@ namespace App\Livewire\Settings; -use Illuminate\Contracts\View\View; use App\Concerns\PasswordValidationRules; use App\Livewire\Actions\Logout; +use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Auth; use Livewire\Component; @@ -32,5 +32,4 @@ class DeleteUserForm extends Component { return view('stylist-theme-common::livewire.settings.delete-user-form'); } - } diff --git a/app/Livewire/Settings/Profile.php b/app/Livewire/Settings/Profile.php index 721a09c..9780f8e 100644 --- a/app/Livewire/Settings/Profile.php +++ b/app/Livewire/Settings/Profile.php @@ -2,10 +2,10 @@ namespace App\Livewire\Settings; -use Illuminate\Contracts\View\View; use App\Concerns\ProfileValidationRules; use Flux\Flux; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Computed; use Livewire\Attributes\Title; @@ -88,5 +88,4 @@ class Profile extends Component return view('stylist-theme-common::livewire.settings.profile') ->layout('stylist-theme-common::components.layouts.app'); } - } diff --git a/app/Livewire/Settings/Security.php b/app/Livewire/Settings/Security.php index d2f781b..5bcb1ad 100644 --- a/app/Livewire/Settings/Security.php +++ b/app/Livewire/Settings/Security.php @@ -2,10 +2,10 @@ namespace App\Livewire\Settings; -use Illuminate\Contracts\View\View; use App\Concerns\PasswordValidationRules; use Exception; use Flux\Flux; +use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\ValidationException; use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication; @@ -316,5 +316,4 @@ class Security extends Component return view('stylist-theme-common::livewire.settings.security') ->layout('stylist-theme-common::components.layouts.app'); } - } diff --git a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php index 821b689..ac1c18c 100644 --- a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php +++ b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php @@ -2,8 +2,8 @@ namespace App\Livewire\Settings\TwoFactor; -use Illuminate\Contracts\View\View; use Exception; +use Illuminate\Contracts\View\View; use Laravel\Fortify\Actions\GenerateNewRecoveryCodes; use Livewire\Attributes\Locked; use Livewire\Component; @@ -54,5 +54,4 @@ class RecoveryCodes extends Component { return view('stylist-theme-common::livewire.settings.two-factor.recovery-codes'); } - } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 264dd38..217638d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,7 +10,6 @@ use Illuminate\Validation\Rules\Password; class AppServiceProvider extends ServiceProvider { - /** * Bootstrap any application services. */ diff --git a/bootstrap/app.php b/bootstrap/app.php index 4936841..1276f26 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -4,6 +4,8 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Request; +use Modules\Localization\Http\Middleware\SetLocale; +use Modules\Sites\Http\Middleware\ResolveSite; use Spatie\Permission\Middleware\PermissionMiddleware; use Spatie\Permission\Middleware\RoleMiddleware; use Spatie\Permission\Middleware\RoleOrPermissionMiddleware; @@ -22,8 +24,8 @@ return Application::configure(basePath: dirname(__DIR__)) ]); $middleware->web(append: [ - \Modules\Sites\Http\Middleware\ResolveSite::class, - \Modules\Localization\Http\Middleware\SetLocale::class, + ResolveSite::class, + SetLocale::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/modules.php b/config/modules.php index 0ba1d01..6f62140 100644 --- a/config/modules.php +++ b/config/modules.php @@ -3,7 +3,6 @@ use mpba\Modules\Activators\DatabaseActivator; use mpba\Modules\Activators\FileActivator; - return [ /* @@ -28,7 +27,7 @@ return [ 'stubs' => [ 'enabled' => false, - 'path' => base_path() . '/Vendors/mpba/laravel-modules/src/Commands/stubs', + 'path' => base_path().'/Vendors/mpba/laravel-modules/src/Commands/stubs', 'files' => [ 'routes/web' => 'Routes/web.php', 'routes/api' => 'Routes/api.php', @@ -121,7 +120,7 @@ return [ 'views' => ['path' => 'Resources/views', 'generate' => true], 'test' => ['path' => 'Tests/Unit', 'generate' => true], 'test-feature' => ['path' => 'Tests/Feature', 'generate' => true], - 'test-browser' => ['path' => 'Tests/Browser', 'generate' => true,], + 'test-browser' => ['path' => 'Tests/Browser', 'generate' => true], 'repository' => ['path' => 'Repositories', 'generate' => false], 'event' => ['path' => 'Events', 'generate' => false], 'listener' => ['path' => 'Listeners', 'generate' => false], diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index ebbe046..2948fc8 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -64,4 +64,4 @@ test('users can logout', function () { $response->assertRedirect(route('home')); $this->assertGuest(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index e10227f..ce043aa 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -69,4 +69,4 @@ test('already verified user visiting verification link is redirected without fir expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); Event::assertNotDispatched(Verified::class); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index 514115a..76b5e34 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -8,4 +8,4 @@ test('confirm password screen can be rendered', function () { $response = $this->actingAs($user)->get(route('password.confirm')); $response->assertOk(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index 586f8a7..9f55aba 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -62,4 +62,4 @@ test('password can be reset with valid token', function () { return true; }); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 874e6fa..f9c6493 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -24,4 +24,4 @@ test('new users can register', function () { ->assertRedirect(route('dashboard', absolute: false)); $this->assertAuthenticated(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php index 2dc7a6a..70a4fae 100644 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -25,4 +25,4 @@ test('two factor challenge can be rendered', function () { 'email' => $user->email, 'password' => 'password', ])->assertRedirect(route('two-factor.login')); -}); \ No newline at end of file +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index d59d2d7..95c6a8b 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -13,4 +13,4 @@ test('authenticated users can visit the dashboard', function () { $response = $this->get(route('dashboard')); $response->assertOk(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index a576279..ef7b533 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -4,4 +4,4 @@ test('returns a successful response', function () { $response = $this->get(route('home')); $response->assertOk(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index 6d30fee..7dc4899 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -73,4 +73,4 @@ test('correct password must be provided to delete account', function () { $response->assertHasErrors(['password']); expect($user->fresh())->not->toBeNull(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php index eaf1563..beb1171 100644 --- a/tests/Feature/Settings/SecurityTest.php +++ b/tests/Feature/Settings/SecurityTest.php @@ -111,4 +111,4 @@ test('correct password must be provided to update password', function () { ->call('updatePassword'); $response->assertHasErrors(['current_password']); -}); \ No newline at end of file +});