diff --git a/README.md b/README.md index 1edd1e63..8379a0a5 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,117 @@ This plugin adds help article management to Filament with admin, frontend, and g ## Installation -You can install the package via composer: +You can install the package via Composer: ```bash composer require tapp/filament-help ``` -You can publish and run the migrations with: +You can publish the config file with: + +```bash +php artisan vendor:publish --tag="filament-help-config" +``` + +This is the contents of the published config file: + +```php +return [ + + /* + |-------------------------------------------------------------------------- + | Help Article Model + |-------------------------------------------------------------------------- + | + | If you extend the HelpArticle model in your application to add custom + | relationships (e.g., tenant relationships), specify your extended model here. + | This ensures Filament resources use your extended model instead of the base model. + | + | Example: \App\Models\HelpArticle::class + | + */ + + 'model' => \Tapp\FilamentHelp\Models\HelpArticle::class, + + /* + |-------------------------------------------------------------------------- + | Tenancy Configuration + |-------------------------------------------------------------------------- + | + | Configure multi-tenancy settings for help articles. + | + */ + + 'tenancy' => [ + /* + * Enable or disable tenancy features globally. + * When enabled, a team_id column will be added to the help_articles table + * and articles will be scoped to the current tenant. + */ + 'enabled' => env('FILAMENT_HELP_TENANCY_ENABLED', false), + + /* + * The column name for the tenant relationship. + * This column will be added to the help_articles table if tenancy is enabled. + * E.g. 'team_id' + */ + 'column' => env('FILAMENT_HELP_TENANCY_COLUMN', null), + + /* + * The tenant model class. + * This should be the same model you use for Filament's tenant feature. + * E.g. \App\Models\Team::class + */ + 'model' => null, + + /* + * The relationship name on the HelpArticle model. + * This is used for Filament's tenant ownership relationship. + * E.g. 'team' + * + */ + 'relationship' => env('FILAMENT_HELP_TENANCY_RELATIONSHIP', null), + + /* + * The foreign key constraint configuration. + */ + 'foreign_key' => [ + 'on_delete' => env('FILAMENT_HELP_TENANCY_ON_DELETE', 'cascade'), // cascade, set null, restrict + 'on_update' => env('FILAMENT_HELP_TENANCY_ON_UPDATE', 'cascade'), // cascade, set null, restrict + ], + + /* + * Enable tenancy scoping per panel type. + * When false, articles will not be scoped by tenant even if global tenancy is enabled. + */ + 'scoping' => [ + 'admin' => env('FILAMENT_HELP_TENANCY_SCOPE_ADMIN', true), + 'frontend' => env('FILAMENT_HELP_TENANCY_SCOPE_FRONTEND', true), + 'guest' => env('FILAMENT_HELP_TENANCY_SCOPE_GUEST', true), + ], + + /* + * Automatically assign tenant on creation. + * When enabled, new articles will automatically get the current tenant ID assigned. + */ + 'auto_assign' => env('FILAMENT_HELP_TENANCY_AUTO_ASSIGN', true), + ], + +]; +``` + +You can publish the migrations with: ```bash php artisan vendor:publish --tag="filament-help-migrations" +``` + +> [!WARNING] +> If you are using multi-tenancy please see the "Multi-Tenancy Support" instructions below **before** publishing and running migrations. + +You can run the migrations with: + +```bash php artisan migrate ``` @@ -67,7 +168,8 @@ public function panel(Panel $panel): Panel ->plugins([ FilamentHelpFrontendPlugin::make(), // Default slug is 'help-articles', so articles will be at {panel-path}/help-articles - // Customize with ->slug('custom-slug') if needed + // Customize with: + // ->slug('custom-slug') ]); } ``` @@ -96,7 +198,8 @@ public function panel(Panel $panel): Panel ->plugins([ FilamentHelpGuestPlugin::make(), // Default slug is 'help', so articles will be at /help (or {panel-path}/help) - // Customize with ->slug('custom-slug') if needed + // Customize with: + // ->slug('custom-slug') ]); } ``` @@ -127,6 +230,198 @@ The frontend and guest panel URLs can be customized using the plugin's `->slug() - **Public/Private**: Control article visibility with `is_public` flag - **Hidden/Draft**: Hide articles from public view with `is_hidden` flag (useful for drafts or archived articles) - **Search & Filter**: Find articles by name and filter by public/hidden status +- **Multi-Tenancy Support**: Optionally scope help articles to teams/organizations + +## Multi-Tenancy Support + +This package supports Filament's multi-tenancy feature, allowing you to scope help articles to specific teams or organizations. + +### Setting Up Multi-Tenancy + +#### ⚠️ Important: Configure Before Migration + +**You MUST enable and configure tenancy BEFORE running migrations!** The migrations check the tenancy configuration to determine whether to add tenant columns to the database tables. Enabling tenancy after running migrations will require manual database modifications. + +1. **Enable tenancy in the config file**: + +Publish the config file: + +```bash +php artisan vendor:publish --tag="filament-help-config" +``` + +Then update `config/filament-help.php`: + +```php +return [ + 'tenancy' => [ + 'enabled' => true, // Enable tenancy + 'model' => \App\Models\Team::class, // Your tenant model + 'column' => 'team_id', // Column name in help_articles table + 'relationship' => 'team', // Relationship name + + // Scoping per panel type + 'scoping' => [ + 'admin' => true, // Scope articles in admin panel + 'frontend' => true, // Scope articles in frontend panel + 'guest' => false, // Don't scope in guest panel (shared articles) + ], + + 'auto_assign' => true, // Auto-assign current tenant to new articles + ], +]; +``` + +Or use environment variables in your `.env` file: + +```env +FILAMENT_HELP_TENANCY_ENABLED=true +FILAMENT_HELP_TENANCY_COLUMN=team_id +FILAMENT_HELP_TENANCY_RELATIONSHIP=team +FILAMENT_HELP_TENANCY_SCOPE_ADMIN=true +FILAMENT_HELP_TENANCY_SCOPE_FRONTEND=true +FILAMENT_HELP_TENANCY_SCOPE_GUEST=false +``` + +2. **Add the tenant relationship to your HelpArticle model**: + +Since the package needs to support various tenant models (Team, Organization, etc.), you need to define the relationship in your application. + +Extend the `HelpArticle` model in your application: + +```php +// app/Models/HelpArticle.php +belongsTo(\App\Models\Team::class); + } +} +``` + +Or if you use a different tenant model: + +```php +public function organization(): BelongsTo +{ + return $this->belongsTo(\App\Models\Organization::class); +} +``` + +**Important**: Make sure the relationship name matches the `relationship` config value you set (e.g., `'team'` or `'organization'`). + +Then, update your config to use your extended model: + +```php +// config/filament-help.php +return [ + 'model' => \App\Models\HelpArticle::class, + + 'tenancy' => [ + 'enabled' => true, + 'model' => \App\Models\Team::class, + 'column' => 'team_id', + 'relationship' => 'team', + // ... + ], +]; +``` + +3. **Run migrations**: + +When tenancy is enabled, the migration will automatically add the tenant column to the `help_articles` table: + +```bash +php artisan migrate +``` + +4. **Configure your Filament panel with tenancy**: + +```php +// In your AdminPanelProvider.php (or wherever you configure your Filament panel) +use Tapp\FilamentHelp\FilamentHelpPlugin; + +public function panel(Panel $panel): Panel +{ + return $panel + ->tenant(\App\Models\Team::class) // Your tenant model + // ... other configuration + ->plugins([ + FilamentHelpPlugin::make(), + ]); +} +``` + +### How It Works + +When tenancy is enabled: + +- **Migration**: The `team_id` column (or your custom column name) is automatically added to the `help_articles` table during migration +- **Admin Panel**: Help articles are automatically scoped to the current tenant. Users can only see and manage articles belonging to their team. +- **Auto-assignment**: When creating a new help article, the tenant ID is automatically assigned to the current tenant. +- **Frontend/Guest**: You can control whether tenancy scoping applies to frontend and guest panels using the config. + +### Configuration Options + +#### Tenancy Column + +Change the column name if you use a different naming convention: + +```php +'column' => 'organization_id', +``` + +#### Tenant Model + +Specify your tenant model: + +```php +'model' => \App\Models\Organization::class, +``` + +#### Scoping Control + +Control which panels apply tenant scoping: + +```php +'scoping' => [ + 'admin' => true, // Articles scoped by tenant in admin + 'frontend' => true, // Articles scoped by tenant in frontend + 'guest' => false, // Articles shared across all tenants in guest panel +], +``` + +#### Foreign Key Constraints + +Configure cascade behavior: + +```php +'foreign_key' => [ + 'on_delete' => 'cascade', // or 'set null', 'restrict' + 'on_update' => 'cascade', // or 'set null', 'restrict' +], +``` + +### Disabling Tenancy + +By default, tenancy is disabled. Help articles are shared across all teams. To use this package without tenancy, simply leave the config as default or set: + +```env +FILAMENT_HELP_TENANCY_ENABLED=false +``` ## Testing diff --git a/config/filament-help.php b/config/filament-help.php new file mode 100644 index 00000000..b030686a --- /dev/null +++ b/config/filament-help.php @@ -0,0 +1,85 @@ + \Tapp\FilamentHelp\Models\HelpArticle::class, + + /* + |-------------------------------------------------------------------------- + | Tenancy Configuration + |-------------------------------------------------------------------------- + | + | Configure multi-tenancy settings for help articles. + | + */ + + 'tenancy' => [ + /* + * Enable or disable tenancy features globally. + * When enabled, a team_id column will be added to the help_articles table + * and articles will be scoped to the current tenant. + */ + 'enabled' => env('FILAMENT_HELP_TENANCY_ENABLED', false), + + /* + * The column name for the tenant relationship. + * This column will be added to the help_articles table if tenancy is enabled. + * E.g. 'team_id' + */ + 'column' => env('FILAMENT_HELP_TENANCY_COLUMN', null), + + /* + * The tenant model class. + * This should be the same model you use for Filament's tenant feature. + * E.g. \App\Models\Team::class + */ + 'model' => null, + + /* + * The relationship name on the HelpArticle model. + * This is used for Filament's tenant ownership relationship. + * E.g. 'team' + * + */ + 'relationship' => env('FILAMENT_HELP_TENANCY_RELATIONSHIP', null), + + /* + * The foreign key constraint configuration. + */ + 'foreign_key' => [ + 'on_delete' => env('FILAMENT_HELP_TENANCY_ON_DELETE', 'cascade'), // cascade, set null, restrict + 'on_update' => env('FILAMENT_HELP_TENANCY_ON_UPDATE', 'cascade'), // cascade, set null, restrict + ], + + /* + * Enable tenancy scoping per panel type. + * When false, articles will not be scoped by tenant even if global tenancy is enabled. + */ + 'scoping' => [ + 'admin' => env('FILAMENT_HELP_TENANCY_SCOPE_ADMIN', true), + 'frontend' => env('FILAMENT_HELP_TENANCY_SCOPE_FRONTEND', true), + 'guest' => env('FILAMENT_HELP_TENANCY_SCOPE_GUEST', true), + ], + + /* + * Automatically assign tenant on creation. + * When enabled, new articles will automatically get the current tenant ID assigned. + */ + 'auto_assign' => env('FILAMENT_HELP_TENANCY_AUTO_ASSIGN', true), + ], + +]; + diff --git a/database/factories/HelpArticleFactory.php b/database/factories/HelpArticleFactory.php index d5e822cf..82a811a1 100644 --- a/database/factories/HelpArticleFactory.php +++ b/database/factories/HelpArticleFactory.php @@ -14,11 +14,19 @@ class HelpArticleFactory extends Factory public function definition(): array { - return [ + $definition = [ 'name' => $this->faker->sentence(3), 'is_public' => $this->faker->boolean(70), // 70% chance of being public 'content' => $this->faker->paragraphs(3, true), ]; + + // Add tenant column if tenancy is enabled + if (config('filament-help.tenancy.enabled', false)) { + $tenantColumn = config('filament-help.tenancy.column') ?? 'team_id'; + $definition[$tenantColumn] = null; // Will be set explicitly or by model boot logic + } + + return $definition; } public function public(): static @@ -41,4 +49,17 @@ public function hidden(): static 'is_hidden' => true, ]); } + + public function forTeam($team): static + { + if (! config('filament-help.tenancy.enabled', false)) { + return $this; + } + + $tenantColumn = config('filament-help.tenancy.column') ?? 'team_id'; + + return $this->state(fn (array $attributes) => [ + $tenantColumn => is_object($team) ? $team->id : $team, + ]); + } } diff --git a/database/migrations/add_is_hidden_to_help_articles_table.php.stub b/database/migrations/add_is_hidden_to_help_articles_table.php.stub index 6c024361..58815f61 100644 --- a/database/migrations/add_is_hidden_to_help_articles_table.php.stub +++ b/database/migrations/add_is_hidden_to_help_articles_table.php.stub @@ -1,5 +1,7 @@ boolean('is_hidden')->default(false)->after('is_public'); }); } }; - diff --git a/database/migrations/create_help_articles_table.php.stub b/database/migrations/create_help_articles_table.php.stub index 108476e8..a6304057 100644 --- a/database/migrations/create_help_articles_table.php.stub +++ b/database/migrations/create_help_articles_table.php.stub @@ -1,5 +1,7 @@ id(); + + // Add tenant column if tenancy is enabled in config + if (config('filament-help.tenancy.enabled', false)) { + $tenantColumn = config('filament-help.tenancy.column') ?? 'team_id'; + $onDelete = config('filament-help.tenancy.foreign_key.on_delete', 'cascade'); + $onUpdate = config('filament-help.tenancy.foreign_key.on_update', 'cascade'); + + $table->foreignId($tenantColumn) + ->nullable() + ->constrained('teams') + ->onDelete($onDelete) + ->onUpdate($onUpdate); + } + $table->string('name'); $table->string('slug')->unique()->nullable(); $table->boolean('is_public')->default(false); @@ -18,4 +34,9 @@ return new class extends Migration $table->timestamps(); }); } + + public function down(): void + { + Schema::dropIfExists('help_articles'); + } }; diff --git a/src/FilamentHelpServiceProvider.php b/src/FilamentHelpServiceProvider.php index d5f16ec5..0e62cd44 100644 --- a/src/FilamentHelpServiceProvider.php +++ b/src/FilamentHelpServiceProvider.php @@ -12,6 +12,7 @@ public function configurePackage(Package $package): void { $package ->name('filament-help') + ->hasConfigFile() ->hasViews() ->hasMigrations([ 'create_help_articles_table', @@ -19,6 +20,7 @@ public function configurePackage(Package $package): void ]) ->hasInstallCommand(function (InstallCommand $command) { $command + ->publishConfigFile() ->publishMigrations() ->askToRunMigrations(); }); diff --git a/src/Models/HelpArticle.php b/src/Models/HelpArticle.php index 17dd9bef..377f2449 100644 --- a/src/Models/HelpArticle.php +++ b/src/Models/HelpArticle.php @@ -18,6 +18,17 @@ class HelpArticle extends Model 'embed', ]; + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + // Dynamically add tenant column to fillable if tenancy is enabled + if (config('filament-help.tenancy.enabled', false)) { + $tenantColumn = config('filament-help.tenancy.column') ?? 'team_id'; + $this->fillable[] = $tenantColumn; + } + } + protected $casts = [ 'is_public' => 'boolean', 'is_hidden' => 'boolean', @@ -33,6 +44,38 @@ public function scopeVisible($query) return $query->where('is_hidden', false); } + /** + * Scope query to only include articles for a specific tenant/team. + */ + public function scopeForTenant($query, $tenant) + { + if (! config('filament-help.tenancy.enabled', false)) { + return $query; + } + + $tenantColumn = config('filament-help.tenancy.column') ?? 'team_id'; + + return $query->where($tenantColumn, $tenant->id); + } + + /** + * Define your tenant relationship here. + * + * Example for Team: + * + * public function team(): BelongsTo + * { + * return $this->belongsTo(\App\Models\Team::class); + * } + * + * Or for Organization: + * + * public function organization(): BelongsTo + * { + * return $this->belongsTo(\App\Models\Organization::class); + * } + */ + protected static function boot() { parent::boot(); @@ -41,6 +84,18 @@ protected static function boot() if (empty($article->slug)) { $article->slug = \Str::slug($article->name); } + + // Auto-assign team_id from current Filament tenant if available and enabled + if (config('filament-help.tenancy.enabled', false) && config('filament-help.tenancy.auto_assign', true)) { + $tenantColumn = config('filament-help.tenancy.column') ?? 'team_id'; + + if (empty($article->{$tenantColumn}) && class_exists(\Filament\Facades\Filament::class)) { + $tenant = \Filament\Facades\Filament::getTenant(); + if ($tenant) { + $article->{$tenantColumn} = $tenant->id; + } + } + } }); static::updating(function ($article) { diff --git a/src/Resources/Frontend/HelpArticleResource.php b/src/Resources/Frontend/HelpArticleResource.php index f3eb0f99..bf5fc2d4 100644 --- a/src/Resources/Frontend/HelpArticleResource.php +++ b/src/Resources/Frontend/HelpArticleResource.php @@ -7,14 +7,18 @@ use Filament\Support\Enums\Alignment; use Filament\Tables\Columns\Layout\Stack; use Filament\Tables\Table; -use Tapp\FilamentHelp\Models\HelpArticle; use Tapp\FilamentHelp\Resources\Frontend\Pages\ListHelpArticles; use Tapp\FilamentHelp\Resources\Frontend\Pages\ViewHelpArticle; use Tapp\FilamentHelp\Tables\Components\HelpArticleCardColumn; class HelpArticleResource extends Resource { - protected static ?string $model = HelpArticle::class; + protected static ?string $model = null; + + public static function getModel(): string + { + return static::$model ?? config('filament-help.model', \Tapp\FilamentHelp\Models\HelpArticle::class); + } protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-question-mark-circle'; @@ -93,8 +97,19 @@ public static function canDelete($record): bool public static function getEloquentQuery(): \Illuminate\Database\Eloquent\Builder { - return parent::getEloquentQuery() + $query = parent::getEloquentQuery() ->public() ->visible(); + + // Apply tenant scoping if enabled + if (config('filament-help.tenancy.enabled', false) && config('filament-help.tenancy.scoping.frontend', true)) { + $tenant = \Filament\Facades\Filament::getTenant(); + if ($tenant) { + $tenantColumn = config('filament-help.tenancy.column') ?? 'team_id'; + $query->where($tenantColumn, $tenant->id); + } + } + + return $query; } } diff --git a/src/Resources/Guest/HelpArticleResource.php b/src/Resources/Guest/HelpArticleResource.php index b8e04ad4..e813067a 100644 --- a/src/Resources/Guest/HelpArticleResource.php +++ b/src/Resources/Guest/HelpArticleResource.php @@ -7,14 +7,18 @@ use Filament\Support\Enums\Alignment; use Filament\Tables\Columns\Layout\Stack; use Filament\Tables\Table; -use Tapp\FilamentHelp\Models\HelpArticle; use Tapp\FilamentHelp\Resources\Guest\Pages\ListHelpArticles; use Tapp\FilamentHelp\Resources\Guest\Pages\ViewHelpArticle; use Tapp\FilamentHelp\Tables\Components\HelpArticleCardColumn; class HelpArticleResource extends Resource { - protected static ?string $model = HelpArticle::class; + protected static ?string $model = null; + + public static function getModel(): string + { + return static::$model ?? config('filament-help.model', \Tapp\FilamentHelp\Models\HelpArticle::class); + } protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-question-mark-circle'; @@ -92,8 +96,19 @@ public static function canDelete($record): bool public static function getEloquentQuery(): \Illuminate\Database\Eloquent\Builder { - return parent::getEloquentQuery() + $query = parent::getEloquentQuery() ->public() ->visible(); + + // Apply tenant scoping if enabled + if (config('filament-help.tenancy.enabled', false) && config('filament-help.tenancy.scoping.guest', false)) { + $tenant = \Filament\Facades\Filament::getTenant(); + if ($tenant) { + $tenantColumn = config('filament-help.tenancy.column') ?? 'team_id'; + $query->where($tenantColumn, $tenant->id); + } + } + + return $query; } } diff --git a/src/Resources/HelpArticleResource.php b/src/Resources/HelpArticleResource.php index 71eb86cb..50c0d97b 100644 --- a/src/Resources/HelpArticleResource.php +++ b/src/Resources/HelpArticleResource.php @@ -12,15 +12,28 @@ use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\DeleteBulkAction; use Illuminate\Support\Str; -use Tapp\FilamentHelp\Models\HelpArticle; use Tapp\FilamentHelp\Resources\HelpArticleResource\Pages; class HelpArticleResource extends Resource { - protected static ?string $model = HelpArticle::class; + protected static ?string $model = null; + + public static function getModel(): string + { + return static::$model ?? config('filament-help.model', \Tapp\FilamentHelp\Models\HelpArticle::class); + } protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-question-mark-circle'; + public static function getTenantOwnershipRelationshipName(): string + { + if (config('filament-help.tenancy.enabled', false)) { + return config('filament-help.tenancy.relationship') ?? 'team'; + } + + return parent::getTenantOwnershipRelationshipName(); + } + public static string|\UnitEnum|null $navigationGroup = 'System'; public static function shouldRegisterNavigation(): bool @@ -150,4 +163,20 @@ public static function getPages(): array 'edit' => Pages\EditHelpArticle::route('/{record}/edit'), ]; } + + public static function getEloquentQuery(): \Illuminate\Database\Eloquent\Builder + { + $query = parent::getEloquentQuery(); + + // Apply tenant scoping if tenancy is enabled + if (config('filament-help.tenancy.enabled', false) && config('filament-help.tenancy.scoping.admin', true)) { + $tenant = \Filament\Facades\Filament::getTenant(); + if ($tenant) { + $tenantColumn = config('filament-help.tenancy.column') ?? 'team_id'; + $query->where($tenantColumn, $tenant->id); + } + } + + return $query; + } }