diff --git a/app-modules/cms/composer.json b/app-modules/cms/composer.json new file mode 100644 index 0000000..8197860 --- /dev/null +++ b/app-modules/cms/composer.json @@ -0,0 +1,24 @@ +{ + "name": "modules/cms", + "description": "", + "type": "library", + "version": "1.0", + "license": "proprietary", + "require": {}, + "autoload": { + "psr-4": { + "Modules\\Cms\\": "src/", + "Modules\\Cms\\Tests\\": "tests/", + "Modules\\Cms\\Database\\Factories\\": "database/factories/", + "Modules\\Cms\\Database\\Seeders\\": "database/seeders/" + } + }, + "minimum-stability": "stable", + "extra": { + "laravel": { + "providers": [ + "Modules\\Cms\\Providers\\CmsServiceProvider" + ] + } + } +} diff --git a/app-modules/cms/database/factories/CmsPageFactory.php b/app-modules/cms/database/factories/CmsPageFactory.php new file mode 100644 index 0000000..00edc60 --- /dev/null +++ b/app-modules/cms/database/factories/CmsPageFactory.php @@ -0,0 +1,31 @@ + + */ +class CmsPageFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'page' => fake()->unique()->slug(), + 'title' => fake()->sentence(3), + 'mm_title' => fake()->sentence(3), + 'meta_tags' => fake()->words(3, true), + 'meta_keywords' => fake()->words(3, true), + 'content' => fake()->paragraphs(3, true), + 'mm_content' => fake()->paragraphs(3, true), + 'is_active' => true, + ]; + } +} diff --git a/app-modules/cms/database/migrations/2026_08_25_000000_create_cms_pages_table.php b/app-modules/cms/database/migrations/2026_08_25_000000_create_cms_pages_table.php new file mode 100644 index 0000000..a9c0cf2 --- /dev/null +++ b/app-modules/cms/database/migrations/2026_08_25_000000_create_cms_pages_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('page')->unique(); + $table->string('title'); + $table->string('mm_title'); + $table->string('meta_tags')->nullable(); + $table->string('meta_keywords')->nullable(); + $table->longText('content')->nullable(); + $table->longText('mm_content')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cms_pages'); + } +}; diff --git a/app-modules/cms/database/seeders/.gitkeep b/app-modules/cms/database/seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/cms/resources/views/.gitkeep b/app-modules/cms/resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/cms/routes/cms-routes.php b/app-modules/cms/routes/cms-routes.php new file mode 100644 index 0000000..e37fca2 --- /dev/null +++ b/app-modules/cms/routes/cms-routes.php @@ -0,0 +1,12 @@ +middleware(['api', 'throttle:api-read'])->group(function () { + Route::get('/cms-pages', [CmsPageController::class, 'index'])->name('cms.pages.index'); + Route::get('/cms-pages/{page}', [CmsPageController::class, 'show'])->name('cms.pages.show'); +}); diff --git a/app-modules/cms/src/CmsPlugin.php b/app-modules/cms/src/CmsPlugin.php new file mode 100644 index 0000000..b383899 --- /dev/null +++ b/app-modules/cms/src/CmsPlugin.php @@ -0,0 +1,38 @@ +discoverResources( + in: __DIR__.'/Filament/Resources', + for: 'Modules\Cms\Filament\Resources', + ) + ->discoverPages( + in: __DIR__.'/Filament/Pages', + for: 'Modules\Cms\Filament\Pages', + ) + ->discoverWidgets( + in: __DIR__.'/Filament/Widgets', + for: 'Modules\Cms\Filament\Widgets', + ); + } + + public function boot(Panel $panel): void {} + + public static function make(): static + { + return app(static::class); + } +} diff --git a/app-modules/cms/src/Filament/Pages/.gitkeep b/app-modules/cms/src/Filament/Pages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/cms/src/Filament/Resources/.gitkeep b/app-modules/cms/src/Filament/Resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/cms/src/Filament/Resources/CmsPages/CmsPageResource.php b/app-modules/cms/src/Filament/Resources/CmsPages/CmsPageResource.php new file mode 100644 index 0000000..2c8d8d4 --- /dev/null +++ b/app-modules/cms/src/Filament/Resources/CmsPages/CmsPageResource.php @@ -0,0 +1,51 @@ + ListCmsPages::route('/'), + 'create' => CreateCmsPage::route('/create'), + 'edit' => EditCmsPage::route('/{record}/edit'), + ]; + } +} diff --git a/app-modules/cms/src/Filament/Resources/CmsPages/Pages/CreateCmsPage.php b/app-modules/cms/src/Filament/Resources/CmsPages/Pages/CreateCmsPage.php new file mode 100644 index 0000000..64c6185 --- /dev/null +++ b/app-modules/cms/src/Filament/Resources/CmsPages/Pages/CreateCmsPage.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('meta_tags') + ->maxLength(255), + TextInput::make('meta_keywords') + ->maxLength(255), + TextInput::make('page') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255) + ->helperText('Unique key used to look up this page, e.g. "miniapp_faq".'), + TextInput::make('title') + ->required() + ->maxLength(255), + TextInput::make('mm_title') + ->required() + ->maxLength(255), + RichEditor::make('content') + ->columnSpanFull(), + RichEditor::make('mm_content') + ->columnSpanFull(), + Toggle::make('is_active') + ->required() + ->default(true), + ]); + } +} diff --git a/app-modules/cms/src/Filament/Resources/CmsPages/Tables/CmsPagesTable.php b/app-modules/cms/src/Filament/Resources/CmsPages/Tables/CmsPagesTable.php new file mode 100644 index 0000000..6dacec0 --- /dev/null +++ b/app-modules/cms/src/Filament/Resources/CmsPages/Tables/CmsPagesTable.php @@ -0,0 +1,46 @@ +columns([ + TextColumn::make('page') + ->searchable() + ->sortable(), + TextColumn::make('title') + ->searchable(), + TextColumn::make('mm_title') + ->searchable(), + IconColumn::make('is_active') + ->boolean(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + TernaryFilter::make('is_active'), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app-modules/cms/src/Filament/Widgets/.gitkeep b/app-modules/cms/src/Filament/Widgets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/cms/src/Http/Controllers/CmsPageController.php b/app-modules/cms/src/Http/Controllers/CmsPageController.php new file mode 100644 index 0000000..7cf038a --- /dev/null +++ b/app-modules/cms/src/Http/Controllers/CmsPageController.php @@ -0,0 +1,28 @@ +where('is_active', true)->paginate() + ); + } + + public function show(string $page): CmsPageResource + { + $cmsPage = CmsPage::query() + ->where('page', $page) + ->where('is_active', true) + ->firstOrFail(); + + return new CmsPageResource($cmsPage); + } +} diff --git a/app-modules/cms/src/Http/Resources/CmsPageResource.php b/app-modules/cms/src/Http/Resources/CmsPageResource.php new file mode 100644 index 0000000..4e8fe6a --- /dev/null +++ b/app-modules/cms/src/Http/Resources/CmsPageResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'page' => $this->page, + 'title' => $this->title, + 'mm_title' => $this->mm_title, + 'meta_tags' => $this->meta_tags, + 'meta_keywords' => $this->meta_keywords, + 'content' => $this->content, + 'mm_content' => $this->mm_content, + ]; + } +} diff --git a/app-modules/cms/src/Models/CmsPage.php b/app-modules/cms/src/Models/CmsPage.php new file mode 100644 index 0000000..5fe45dc --- /dev/null +++ b/app-modules/cms/src/Models/CmsPage.php @@ -0,0 +1,53 @@ + */ + use HasFactory, LogsActivity; + + /** + * Full CRUD audit trail โ€” CMS content writes are staff-only and + * infrequent, so logging every attribute change is affordable + * (domain.md ยง6; T6.2). + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('cms'); + } + + /** + * @var list + */ + protected $fillable = [ + 'page', + 'title', + 'mm_title', + 'meta_tags', + 'meta_keywords', + 'content', + 'mm_content', + 'is_active', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'is_active' => 'boolean', + ]; + } +} diff --git a/app-modules/cms/src/Providers/CmsServiceProvider.php b/app-modules/cms/src/Providers/CmsServiceProvider.php new file mode 100644 index 0000000..5364a26 --- /dev/null +++ b/app-modules/cms/src/Providers/CmsServiceProvider.php @@ -0,0 +1,12 @@ +admin = User::factory()->create()->assignRole('admin'); + $this->actingAs($this->admin); +}); + +test('can list cms pages', function () { + $pages = CmsPage::factory()->count(3)->create(); + + Livewire::test(ListCmsPages::class) + ->assertOk() + ->assertCanSeeTableRecords($pages); +}); + +test('can create a cms page', function () { + $page = CmsPage::factory()->make(); + + Livewire::test(CreateCmsPage::class) + ->fillForm([ + 'page' => $page->page, + 'title' => $page->title, + 'mm_title' => $page->mm_title, + 'meta_tags' => $page->meta_tags, + 'meta_keywords' => $page->meta_keywords, + 'content' => $page->content, + 'mm_content' => $page->mm_content, + 'is_active' => true, + ]) + ->call('create') + ->assertNotified() + ->assertRedirect(); + + assertDatabaseHas(CmsPage::class, [ + 'page' => $page->page, + 'title' => $page->title, + ]); +}); + +test('can edit a cms page', function () { + $page = CmsPage::factory()->create(); + + Livewire::test(EditCmsPage::class, ['record' => $page->getRouteKey()]) + ->assertOk() + ->fillForm(['title' => 'Updated Title']) + ->call('save') + ->assertNotified(); + + assertDatabaseHas(CmsPage::class, [ + 'id' => $page->id, + 'title' => 'Updated Title', + ]); +}); + +test('requires a unique page slug', function () { + CmsPage::factory()->create(['page' => 'miniapp_faq']); + $page = CmsPage::factory()->make(['page' => 'miniapp_faq']); + + Livewire::test(CreateCmsPage::class) + ->fillForm([ + 'page' => $page->page, + 'title' => $page->title, + 'mm_title' => $page->mm_title, + ]) + ->call('create') + ->assertHasFormErrors(['page' => 'unique']); +}); diff --git a/app-modules/cms/tests/Feature/CmsReadApiTest.php b/app-modules/cms/tests/Feature/CmsReadApiTest.php new file mode 100644 index 0000000..b54b548 --- /dev/null +++ b/app-modules/cms/tests/Feature/CmsReadApiTest.php @@ -0,0 +1,37 @@ +create(['is_active' => true]); + CmsPage::factory()->create(['is_active' => false]); + + $this->getJson('/api/v1/cms-pages') + ->assertSuccessful() + ->assertJsonCount(1, 'data') + ->assertJsonFragment(['id' => $active->id]); +}); + +test('does not require authentication', function () { + CmsPage::factory()->create(['is_active' => true]); + + $this->getJson('/api/v1/cms-pages')->assertSuccessful(); +}); + +test('shows an active cms page by its page slug', function () { + $page = CmsPage::factory()->create(['page' => 'miniapp_faq', 'is_active' => true]); + + $this->getJson('/api/v1/cms-pages/miniapp_faq') + ->assertSuccessful() + ->assertJsonFragment(['id' => $page->id, 'page' => 'miniapp_faq']); +}); + +test('returns not found for an inactive page', function () { + CmsPage::factory()->create(['page' => 'miniapp_faq', 'is_active' => false]); + + $this->getJson('/api/v1/cms-pages/miniapp_faq')->assertNotFound(); +}); + +test('returns not found for an unknown page slug', function () { + $this->getJson('/api/v1/cms-pages/unknown-page')->assertNotFound(); +}); diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index e387ad3..a7d2554 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -23,6 +23,7 @@ use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; use Modules\Booking\BookingPlugin; use Modules\Catalog\CatalogPlugin; +use Modules\Cms\CmsPlugin; use Modules\Identity\IdentityPlugin; use Modules\Payment\PaymentPlugin; use Modules\Reporting\ReportingPlugin; @@ -49,6 +50,7 @@ class AdminPanelProvider extends PanelProvider NavigationGroup::make()->label('Routing'), NavigationGroup::make()->label('Operations'), NavigationGroup::make()->label('Reports'), + NavigationGroup::make()->label('CMS'), ]) ->plugins([ CatalogPlugin::make(), @@ -57,6 +59,7 @@ class AdminPanelProvider extends PanelProvider PaymentPlugin::make(), IdentityPlugin::make(), ReportingPlugin::make(), + CmsPlugin::make(), // T6.5 โ€” ops convenience for browsing storage/logs/*.log // in-browser; distinct from the structured, per-model audit // trail (AuditLogResource, T6.2). No extra permission gate: diff --git a/composer.json b/composer.json index 30ae070..76c5e30 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "internachi/modular": "^3.0", "modules/booking": "*", "modules/catalog": "*", + "modules/cms": "*", "modules/identity": "*", "modules/payment": "*", "modules/reporting": "*", diff --git a/composer.lock b/composer.lock index 0d76063..c29cf09 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b3018ca42fa6d16d0da6113b3aa6b1a8", + "content-hash": "00ca2f59b7cce7ed3ade4856855a5d51", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -4727,6 +4727,38 @@ "relative": true } }, + { + "name": "modules/cms", + "version": "1.0", + "dist": { + "type": "path", + "url": "app-modules/cms", + "reference": "1269139b9cd2314b5afa6f734a2fdf3462717e4f" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Modules\\Cms\\Providers\\CmsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Modules\\Cms\\": "src/", + "Modules\\Cms\\Tests\\": "tests/", + "Modules\\Cms\\Database\\Factories\\": "database/factories/", + "Modules\\Cms\\Database\\Seeders\\": "database/seeders/" + } + }, + "license": [ + "proprietary" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, { "name": "modules/identity", "version": "1.0",