From 2194802bed835f2a044fa9de0dac0609c53c989a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 27 Sep 2026 01:18:23 +0000 Subject: [PATCH] Add firm metadata and a publication gate for library imports. Items can store firm path, library area, committee, and project tags. Manifest imports land as draft or pending and stay out of search until a gatekeeper publishes them. --- CHANGELOG.md | 7 + README.md | 25 ++ config/filament-library.php | 19 ++ ...n_metadata_to_library_items_table.php.stub | 48 ++++ src/Commands/ImportLibraryCommand.php | 51 ++++ src/Commands/ReviewLibraryItemCommand.php | 78 +++++ src/Enums/LibraryPublicationStatus.php | 40 +++ src/Events/LibraryItemPublished.php | 21 ++ src/Events/LibraryItemRejected.php | 22 ++ src/FilamentLibraryPlugin.php | 76 ++--- src/FilamentLibraryServiceProvider.php | 5 + src/Models/LibraryItem.php | 238 ++++++++++++++++ src/Resources/LibraryItemResource.php | 125 ++++++++ src/Resources/Pages/EditFile.php | 2 + src/Resources/Pages/EditFolder.php | 2 + src/Resources/Pages/EditLink.php | 2 + src/Resources/Pages/GatekeeperQueue.php | 46 +++ src/Resources/Pages/ListLibraryItems.php | 6 +- src/Resources/Pages/PublicLibrary.php | 7 +- src/Resources/Pages/SearchAll.php | 7 +- src/Services/LibraryImportResult.php | 27 ++ src/Services/LibraryImporter.php | 251 ++++++++++++++++ tests/Unit/LibraryPublicationTest.php | 268 ++++++++++++++++++ 23 files changed, 1337 insertions(+), 36 deletions(-) create mode 100644 database/migrations/add_publication_metadata_to_library_items_table.php.stub create mode 100644 src/Commands/ImportLibraryCommand.php create mode 100644 src/Commands/ReviewLibraryItemCommand.php create mode 100644 src/Enums/LibraryPublicationStatus.php create mode 100644 src/Events/LibraryItemPublished.php create mode 100644 src/Events/LibraryItemRejected.php create mode 100644 src/Resources/Pages/GatekeeperQueue.php create mode 100644 src/Services/LibraryImportResult.php create mode 100644 src/Services/LibraryImporter.php create mode 100644 tests/Unit/LibraryPublicationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e2d16a..8043e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to `:package_name` will be documented in this file. +## Unreleased + +* Library items can store firm path, library area, committee, and project tags +* Manifest import lands each row as draft or pending and never auto-publishes +* Search and library browse exclude draft, pending, and rejected items until a gatekeeper publishes them +* Gatekeeper queue, review command, and publish / reject events + ## v1.4.1 - 2026-09-07 Fix User column searchable() callback for Filament 5.7.8 — pass custom search via named query: argument so Filament does not treat the closure as isSearchable. diff --git a/README.md b/README.md index 08f6dff..fd3fba9 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,14 @@ public function boot(): void } ``` +### `LibraryItemPublished` + +[`Tapp\FilamentLibrary\Events\LibraryItemPublished`](src/Events/LibraryItemPublished.php) is fired when a gatekeeper publishes an item. Listen here to update a search index. Imports do not fire this event. + +### `LibraryItemRejected` + +[`Tapp\FilamentLibrary\Events\LibraryItemRejected`](src/Events/LibraryItemRejected.php) is fired when a gatekeeper rejects an item. The item stays out of search. + ### `LibraryFileRestored` [`Tapp\FilamentLibrary\Events\LibraryFileRestored`](src/Events/LibraryFileRestored.php) is fired after a soft-deleted `LibraryItem` of type `file` is restored (for example via Filament's Restore action). @@ -271,6 +279,23 @@ public function boot(): void } ``` +## Publication and firm metadata + +Library items can carry the path to the real file and the metadata that arrives with a manifest: + +- `firm_path` — path to the real file (Copy Path reads this) +- `library_area` — folder or Library area +- `committee` — committee that owns the item +- `project_tags` — project tags from the manifest + +Imports do not publish themselves. `php artisan filament-library:import {manifest.csv} --user={id}` creates each row as `pending` (or `--status=draft`). A `published` value in the file is ignored. Draft, pending, and rejected items are excluded from Search All and from the main Library and Public Library lists. `LibraryItem::query()->searchable()` is the same rule for Ask or any other search. + +A gatekeeper publishes or rejects an item with `php artisan filament-library:review {id} publish` or `reject`, or from the Gatekeeper Queue when the library admin check passes. Publishing dispatches `LibraryItemPublished`. Rejecting dispatches `LibraryItemRejected` and keeps the item out of search. `return` sends a pending item back to draft, which is also excluded from search. + +Ordinary creates stay published so existing libraries keep working. Set `publication.new_items_require_approval` to `true` when new items should wait for a gatekeeper too. Set `publication.committees` to the allowed committee names (for example `['Design', 'Technical']`) when imports should reject any other committee. Leave it empty to accept any committee name. + +Manifest columns: `name`, `firm_path`, `library_area` (or `folder`), `committee`, `project_tags` (split on `|`, `;`, or `,`), `type` (`file`, `folder`, or `link`), `url`, and `status` (`draft` or `pending`). + ## Testing ```bash diff --git a/config/filament-library.php b/config/filament-library.php index 9dadae1..5b3ad79 100644 --- a/config/filament-library.php +++ b/config/filament-library.php @@ -226,6 +226,25 @@ | Configure text-based file previews for markdown and JSON exports. | */ + /* + |-------------------------------------------------------------------------- + | Publication + |-------------------------------------------------------------------------- + | + | Imports land as draft or pending and stay out of search until a + | gatekeeper publishes them. Rejected items stay out of search. + | + | Existing items and ordinary creates stay published unless + | new_items_require_approval is turned on. committees restricts the + | committee values an import will accept. Leave it empty to allow any + | committee name. Example: ['Design', 'Technical']. + | + */ + 'publication' => [ + 'new_items_require_approval' => env('FILAMENT_LIBRARY_NEW_ITEMS_REQUIRE_APPROVAL', false), + 'committees' => [], + ], + 'preview' => [ 'text_max_bytes' => 2 * 1024 * 1024, 'markdown_extensions' => ['md', 'markdown', 'mdown'], diff --git a/database/migrations/add_publication_metadata_to_library_items_table.php.stub b/database/migrations/add_publication_metadata_to_library_items_table.php.stub new file mode 100644 index 0000000..37601a2 --- /dev/null +++ b/database/migrations/add_publication_metadata_to_library_items_table.php.stub @@ -0,0 +1,48 @@ +string('firm_path', 1024)->nullable(); + $table->string('library_area')->nullable(); + $table->string('committee')->nullable(); + $table->json('project_tags')->nullable(); + $table->string('publication_status')->default('published')->index(); + $table->timestamp('published_at')->nullable(); + $table->foreignId('published_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('rejected_at')->nullable(); + $table->foreignId('rejected_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('rejection_reason')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('library_items', function (Blueprint $table) { + $table->dropConstrainedForeignId('published_by'); + $table->dropConstrainedForeignId('rejected_by'); + $table->dropColumn([ + 'firm_path', + 'library_area', + 'committee', + 'project_tags', + 'publication_status', + 'published_at', + 'rejected_at', + 'rejection_reason', + ]); + }); + } +}; diff --git a/src/Commands/ImportLibraryCommand.php b/src/Commands/ImportLibraryCommand.php new file mode 100644 index 0000000..b807eb0 --- /dev/null +++ b/src/Commands/ImportLibraryCommand.php @@ -0,0 +1,51 @@ +option('user'); + + if ($userId < 1 || ! DB::table('users')->where('id', $userId)->exists()) { + $this->error('Pass a valid --user id. Imports are recorded against that user.'); + + return self::FAILURE; + } + + $status = LibraryPublicationStatus::tryFrom(strtolower((string) $this->option('status'))); + + if (! in_array($status, [LibraryPublicationStatus::Draft, LibraryPublicationStatus::Pending], true)) { + $this->error('Status must be draft or pending. Imports are not published automatically.'); + + return self::FAILURE; + } + + $result = $importer->importCsv((string) $this->argument('path'), $userId, $status); + + foreach ($result->errors as $error) { + $this->error($error); + } + + $this->info("Imported {$result->importedCount()} library item(s) as {$status->value}."); + + if (! $result->succeeded()) { + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/src/Commands/ReviewLibraryItemCommand.php b/src/Commands/ReviewLibraryItemCommand.php new file mode 100644 index 0000000..be67529 --- /dev/null +++ b/src/Commands/ReviewLibraryItemCommand.php @@ -0,0 +1,78 @@ +find($this->argument('id')); + + if (! $item instanceof LibraryItem) { + $this->error('Library item was not found.'); + + return self::FAILURE; + } + + $gatekeeperId = $this->gatekeeperId(); + + if ($gatekeeperId === false) { + return self::FAILURE; + } + + $action = strtolower((string) $this->argument('action')); + $reason = $this->option('reason'); + $reason = is_string($reason) && trim($reason) !== '' ? trim($reason) : null; + + match ($action) { + 'publish' => $item->publish($gatekeeperId), + 'reject' => $item->reject($gatekeeperId, $reason), + 'return' => $item->returnToDraft($gatekeeperId), + default => null, + }; + + if (! in_array($action, ['publish', 'reject', 'return'], true)) { + $this->error('Action must be publish, reject, or return.'); + + return self::FAILURE; + } + + $item->refresh(); + $this->info("Library item {$item->getKey()} is now {$item->publication_status->value}."); + + return self::SUCCESS; + } + + protected function gatekeeperId(): int | false | null + { + $userId = $this->option('user'); + + if ($userId === null || $userId === '') { + return null; + } + + $userId = (int) $userId; + + if ($userId < 1 || ! DB::table('users')->where('id', $userId)->exists()) { + $this->error('Gatekeeper user was not found.'); + + return false; + } + + return $userId; + } +} diff --git a/src/Enums/LibraryPublicationStatus.php b/src/Enums/LibraryPublicationStatus.php new file mode 100644 index 0000000..6d1d027 --- /dev/null +++ b/src/Enums/LibraryPublicationStatus.php @@ -0,0 +1,40 @@ + 'Draft', + self::Pending => 'Pending', + self::Published => 'Published', + self::Rejected => 'Rejected', + }; + } + + /** + * @return array + */ + public static function options(): array + { + $options = []; + + foreach (self::cases() as $status) { + $options[$status->value] = $status->label(); + } + + return $options; + } +} diff --git a/src/Events/LibraryItemPublished.php b/src/Events/LibraryItemPublished.php new file mode 100644 index 0000000..4761db9 --- /dev/null +++ b/src/Events/LibraryItemPublished.php @@ -0,0 +1,21 @@ +getId(); $libraryItemResourceClass = static::libraryItemResourceClass(); + $navigationItems = $this->applyNavigationVisibility([ + NavigationItem::make('Library') + ->url(fn () => $libraryItemResourceClass::getUrl('index')) + ->icon('heroicon-o-building-library') + ->group('Resource Library') + ->sort(1) + ->isActiveWhen(fn () => request()->routeIs("filament.{$panelId}.resources.library.index")), + NavigationItem::make('Search All') + ->url(fn () => $libraryItemResourceClass::getUrl('search-all')) + ->icon('heroicon-o-magnifying-glass') + ->group('Resource Library') + ->sort(2) + ->isActiveWhen(fn () => request()->routeIs("filament.{$panelId}.resources.library.search-all")), + NavigationItem::make('My Documents') + ->url(fn () => $libraryItemResourceClass::getUrl('my-documents')) + ->icon('heroicon-o-folder') + ->group('Resource Library') + ->sort(3) + ->isActiveWhen(fn () => request()->routeIs("filament.{$panelId}.resources.library.my-documents")), + ...static::sharedWithMeNavigationItem($panelId, $libraryItemResourceClass), + NavigationItem::make('Created by Me') + ->url(fn () => $libraryItemResourceClass::getUrl('created-by-me')) + ->icon('heroicon-o-user') + ->group('Resource Library') + ->sort(5) + ->isActiveWhen(fn () => request()->routeIs("filament.{$panelId}.resources.library.created-by-me")), + NavigationItem::make('Favorites') + ->url(fn () => $libraryItemResourceClass::getUrl('favorites')) + ->icon('heroicon-o-star') + ->group('Resource Library') + ->sort(6) + ->isActiveWhen(fn () => request()->routeIs("filament.{$panelId}.resources.library.favorites")), + ]); + + $navigationItems[] = NavigationItem::make('Gatekeeper Queue') + ->url(fn () => $libraryItemResourceClass::getUrl('gatekeeper-queue')) + ->icon('heroicon-o-clipboard-document-check') + ->group('Resource Library') + ->sort(7) + ->isActiveWhen(fn () => request()->routeIs("filament.{$panelId}.resources.library.gatekeeper-queue")) + ->visible(fn (): bool => $this->isNavigationVisible() && static::isLibraryAdmin(auth()->user())); + $panel ->resources( array_values(config('filament-library.resources', [ LibraryItemResource::class, ])), ) - ->navigationItems($this->applyNavigationVisibility([ - NavigationItem::make('Library') - ->url(fn () => $libraryItemResourceClass::getUrl('index')) - ->icon('heroicon-o-building-library') - ->group('Resource Library') - ->sort(1) - ->isActiveWhen(fn () => request()->routeIs("filament.{$panelId}.resources.library.index")), - NavigationItem::make('Search All') - ->url(fn () => $libraryItemResourceClass::getUrl('search-all')) - ->icon('heroicon-o-magnifying-glass') - ->group('Resource Library') - ->sort(2) - ->isActiveWhen(fn () => request()->routeIs("filament.{$panelId}.resources.library.search-all")), - NavigationItem::make('My Documents') - ->url(fn () => $libraryItemResourceClass::getUrl('my-documents')) - ->icon('heroicon-o-folder') - ->group('Resource Library') - ->sort(3) - ->isActiveWhen(fn () => request()->routeIs("filament.{$panelId}.resources.library.my-documents")), - ...static::sharedWithMeNavigationItem($panelId, $libraryItemResourceClass), - NavigationItem::make('Created by Me') - ->url(fn () => $libraryItemResourceClass::getUrl('created-by-me')) - ->icon('heroicon-o-user') - ->group('Resource Library') - ->sort(5) - ->isActiveWhen(fn () => request()->routeIs("filament.{$panelId}.resources.library.created-by-me")), - NavigationItem::make('Favorites') - ->url(fn () => $libraryItemResourceClass::getUrl('favorites')) - ->icon('heroicon-o-star') - ->group('Resource Library') - ->sort(6) - ->isActiveWhen(fn () => request()->routeIs("filament.{$panelId}.resources.library.favorites")), - ])); + ->navigationItems($navigationItems); } public function boot(Panel $panel): void diff --git a/src/FilamentLibraryServiceProvider.php b/src/FilamentLibraryServiceProvider.php index ef40b77..74ea802 100644 --- a/src/FilamentLibraryServiceProvider.php +++ b/src/FilamentLibraryServiceProvider.php @@ -13,6 +13,8 @@ use Spatie\LaravelPackageTools\PackageServiceProvider; use Spatie\MediaLibrary\MediaCollections\Models\Media; use Tapp\FilamentLibrary\Commands\FilamentLibraryCommand; +use Tapp\FilamentLibrary\Commands\ImportLibraryCommand; +use Tapp\FilamentLibrary\Commands\ReviewLibraryItemCommand; use Tapp\FilamentLibrary\Commands\SeedLibraryCommand; use Tapp\FilamentLibrary\Events\LibraryFileRestored; use Tapp\FilamentLibrary\Events\LibraryFileStored; @@ -147,6 +149,8 @@ protected function getCommands(): array { return [ FilamentLibraryCommand::class, + ImportLibraryCommand::class, + ReviewLibraryItemCommand::class, SeedLibraryCommand::class, ]; } @@ -236,6 +240,7 @@ protected function getMigrations(): array 'create_library_item_permissions_table', 'create_library_item_tags_table', 'create_library_item_favorites_table', + 'add_publication_metadata_to_library_items_table', ]; } } diff --git a/src/Models/LibraryItem.php b/src/Models/LibraryItem.php index de373ba..f7e37a5 100644 --- a/src/Models/LibraryItem.php +++ b/src/Models/LibraryItem.php @@ -2,6 +2,7 @@ namespace Tapp\FilamentLibrary\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -15,6 +16,9 @@ use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; +use Tapp\FilamentLibrary\Enums\LibraryPublicationStatus; +use Tapp\FilamentLibrary\Events\LibraryItemPublished; +use Tapp\FilamentLibrary\Events\LibraryItemRejected; use Tapp\FilamentLibrary\FilamentLibraryPlugin; use Tapp\FilamentLibrary\Models\Traits\BelongsToTenant; @@ -29,6 +33,16 @@ * @property string|null $external_url * @property string|null $link_description * @property string|null $general_access + * @property string|null $firm_path + * @property string|null $library_area + * @property string|null $committee + * @property array|null $project_tags + * @property LibraryPublicationStatus|null $publication_status + * @property Carbon|null $published_at + * @property int|null $published_by + * @property Carbon|null $rejected_at + * @property int|null $rejected_by + * @property string|null $rejection_reason * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property Carbon|null $deleted_at @@ -46,6 +60,8 @@ class LibraryItem extends Model implements HasMedia use InteractsWithMedia; use SoftDeletes; + protected static bool $syncingProjectTags = false; + protected $fillable = [ 'name', 'slug', @@ -56,12 +72,21 @@ class LibraryItem extends Model implements HasMedia 'external_url', 'link_description', 'general_access', + 'firm_path', + 'library_area', + 'committee', + 'project_tags', + 'publication_status', ]; protected $casts = [ 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', + 'project_tags' => 'array', + 'publication_status' => LibraryPublicationStatus::class, + 'published_at' => 'datetime', + 'rejected_at' => 'datetime', ]; /** @@ -76,6 +101,12 @@ protected static function boot(): void $item->slug = static::generateUniqueSlug($item->name, $item->parent_id); } + if ($item->publication_status === null) { + $item->publication_status = config('filament-library.publication.new_items_require_approval', false) + ? LibraryPublicationStatus::Pending + : LibraryPublicationStatus::Published; + } + // Set created_by and updated_by on creation (like Laravel does with timestamps) if (auth()->check()) { $item->created_by = auth()->id(); @@ -109,6 +140,23 @@ protected static function boot(): void $item->updated_by = auth()->id(); } }); + + static::saved(function (self $item): void { + $shouldSync = is_array($item->project_tags) + && ($item->wasRecentlyCreated || $item->wasChanged('project_tags')); + + if (static::$syncingProjectTags || ! $shouldSync) { + return; + } + + static::$syncingProjectTags = true; + + try { + $item->syncProjectTagRecords(); + } finally { + static::$syncingProjectTags = false; + } + }); } /** @@ -709,4 +757,194 @@ public function getIsFavoriteAttribute(): bool { return $this->isFavorite(); } + + /** + * Split a manifest tag list into unique names. + * + * @return list + */ + public static function normalizeProjectTags(array | string | null $tags): array + { + if (is_string($tags)) { + $tags = preg_split('/[|,;]/', $tags) ?: []; + } + + if ($tags === null) { + return []; + } + + $normalized = []; + + foreach ($tags as $tag) { + $tag = trim((string) $tag); + + if ($tag !== '') { + $normalized[] = $tag; + } + } + + return array_values(array_unique($normalized)); + } + + /** + * Mirror project_tags onto library item tag records. + */ + public function syncProjectTagRecords(): void + { + $names = self::normalizeProjectTags($this->project_tags); + $tagModel = FilamentLibraryPlugin::libraryItemTagModelClass(); + $ids = []; + + foreach ($names as $name) { + $ids[] = $this->findOrCreateProjectTag($tagModel, $name)->getKey(); + } + + $this->tags()->sync($ids); + } + + public function isSearchable(): bool + { + return $this->publication_status === LibraryPublicationStatus::Published; + } + + /** + * Published items only. Draft, pending, and rejected items stay out of search. + */ + public function scopeSearchable(Builder $query): Builder + { + return static::limitToPublished($query); + } + + /** + * Items still waiting on a gatekeeper, including rejections. + */ + public function scopeUnpublished(Builder $query): Builder + { + return static::limitToUnpublished($query); + } + + public static function limitToPublished(Builder $query): Builder + { + return $query->where( + $query->getModel()->qualifyColumn('publication_status'), + LibraryPublicationStatus::Published->value, + ); + } + + public static function limitToUnpublished(Builder $query): Builder + { + return $query->where( + $query->getModel()->qualifyColumn('publication_status'), + '!=', + LibraryPublicationStatus::Published->value, + ); + } + + public function scopeInLibraryArea(Builder $query, string $libraryArea): Builder + { + return $query->where($query->getModel()->qualifyColumn('library_area'), $libraryArea); + } + + public function scopeForCommittee(Builder $query, string $committee): Builder + { + return $query->where($query->getModel()->qualifyColumn('committee'), $committee); + } + + public function scopeWithProjectTag(Builder $query, string $tag): Builder + { + return $query->whereJsonContains( + $query->getModel()->qualifyColumn('project_tags'), + $tag, + ); + } + + public function publish(Model | int | null $gatekeeper = null): void + { + if ($this->publication_status === LibraryPublicationStatus::Published) { + return; + } + + $this->forceFill([ + 'publication_status' => LibraryPublicationStatus::Published, + 'published_at' => now(), + 'published_by' => $this->gatekeeperId($gatekeeper), + 'rejected_at' => null, + 'rejected_by' => null, + 'rejection_reason' => null, + ])->save(); + + event(new LibraryItemPublished($this)); + } + + public function reject(Model | int | null $gatekeeper = null, ?string $reason = null): void + { + $reason = $reason !== null ? trim($reason) : null; + $reason = $reason === '' ? null : $reason; + + $this->forceFill([ + 'publication_status' => LibraryPublicationStatus::Rejected, + 'rejected_at' => now(), + 'rejected_by' => $this->gatekeeperId($gatekeeper), + 'rejection_reason' => $reason, + 'published_at' => null, + 'published_by' => null, + ])->save(); + + event(new LibraryItemRejected($this, $reason)); + } + + public function returnToDraft(Model | int | null $gatekeeper = null): void + { + $this->forceFill([ + 'publication_status' => LibraryPublicationStatus::Draft, + 'published_at' => null, + 'published_by' => null, + 'updated_by' => $this->gatekeeperId($gatekeeper) ?? $this->updated_by, + ])->save(); + } + + protected function gatekeeperId(Model | int | null $gatekeeper): ?int + { + if ($gatekeeper instanceof Model) { + return $gatekeeper->getKey() !== null ? (int) $gatekeeper->getKey() : null; + } + + if (is_int($gatekeeper)) { + return $gatekeeper; + } + + $authId = auth()->id(); + + return $authId !== null ? (int) $authId : null; + } + + /** + * @param class-string $tagModel + */ + protected function findOrCreateProjectTag(string $tagModel, string $name): LibraryItemTag + { + $existing = $tagModel::query()->where('name', $name)->first(); + + if ($existing instanceof LibraryItemTag) { + return $existing; + } + + $baseSlug = Str::slug($name); + $baseSlug = $baseSlug !== '' ? $baseSlug : 'tag'; + $slug = $baseSlug; + $counter = 1; + + while ($tagModel::query()->where('slug', $slug)->exists()) { + $slug = $baseSlug . '-' . $counter; + $counter++; + } + + /** @var LibraryItemTag $tag */ + $tag = $tagModel::query()->create([ + 'name' => $name, + 'slug' => $slug, + ]); + + return $tag; + } } diff --git a/src/Resources/LibraryItemResource.php b/src/Resources/LibraryItemResource.php index f1022c6..82dc136 100644 --- a/src/Resources/LibraryItemResource.php +++ b/src/Resources/LibraryItemResource.php @@ -14,6 +14,7 @@ use Filament\Actions\RestoreBulkAction; use Filament\Forms\Components\Select; use Filament\Forms\Components\SpatieMediaLibraryFileUpload; +use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Resources\Pages\PageRegistration; @@ -21,6 +22,8 @@ use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Model; +use Tapp\FilamentLibrary\Enums\LibraryPublicationStatus; use Tapp\FilamentLibrary\FilamentLibraryPlugin; use Tapp\FilamentLibrary\Models\LibraryItem; use Tapp\FilamentLibrary\Resources\RelationManagers\LibraryItemPermissionsRelationManager; @@ -145,9 +148,53 @@ public static function form(Schema $schema): Schema ->label('Description') ->visible(fn (callable $get) => $get('type') === 'link') ->rows(3), + + ...static::firmMetadataComponents(), ]); } + /** + * Firm path and manifest metadata carried by a library item. + * + * Project tags are omitted on edit screens that already manage the tags + * relationship, so an empty tags input does not clear tags that were + * attached at import. + * + * @return array + */ + public static function firmMetadataComponents(bool $includeProjectTags = true): array + { + $committees = config('filament-library.publication.committees', []); + $committee = is_array($committees) && $committees !== [] + ? Select::make('committee') + ->label('Committee') + ->options(array_combine($committees, $committees)) + ->native(false) + : TextInput::make('committee') + ->label('Committee') + ->maxLength(255); + + $components = [ + TextInput::make('firm_path') + ->label('Firm path') + ->maxLength(1024) + ->helperText('Path to the real file. Copy Path uses this value.'), + TextInput::make('library_area') + ->label('Library area') + ->maxLength(255) + ->helperText('Folder or Library area from the manifest.'), + $committee, + ]; + + if ($includeProjectTags) { + $components[] = TagsInput::make('project_tags') + ->label('Project tags') + ->placeholder('Add a project tag'); + } + + return $components; + } + public static function folderForm(Schema $schema): Schema { return $schema @@ -236,6 +283,39 @@ public static function table(Table $table): Table ->limit(50) ->tooltip(fn (?LibraryItem $record) => $record?->external_url) ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('firm_path') + ->label('Firm path') + ->limit(40) + ->tooltip(fn (?LibraryItem $record) => $record?->firm_path) + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('library_area') + ->label('Library area') + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('committee') + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('publication_status') + ->label('Publication') + ->badge() + ->formatStateUsing(function (LibraryPublicationStatus | string | null $state): string { + $status = $state instanceof LibraryPublicationStatus + ? $state + : LibraryPublicationStatus::tryFrom((string) $state); + + return $status?->label() ?? (string) $state; + }) + ->color(function (LibraryPublicationStatus | string | null $state): string { + $status = $state instanceof LibraryPublicationStatus + ? $state + : LibraryPublicationStatus::tryFrom((string) $state); + + return match ($status) { + LibraryPublicationStatus::Published => 'success', + LibraryPublicationStatus::Pending => 'warning', + LibraryPublicationStatus::Rejected => 'danger', + default => 'gray', + }; + }) + ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('general_access') ->label('Permissions') ->badge() @@ -275,6 +355,9 @@ public static function table(Table $table): Table 'file' => 'File', 'link' => 'External Link', ]), + Tables\Filters\SelectFilter::make('publication_status') + ->label('Publication') + ->options(LibraryPublicationStatus::options()), Tables\Filters\SelectFilter::make('tags') ->label('Tags') ->relationship('tags', 'name') @@ -285,6 +368,42 @@ public static function table(Table $table): Table ]) ->recordActions([ ActionGroup::make([ + Action::make('publish') + ->label('Publish') + ->icon('heroicon-o-check') + ->color('success') + ->requiresConfirmation() + ->visible(fn (LibraryItem $record): bool => static::gatekeeperCanReview() && ! $record->isSearchable()) + ->action(function (LibraryItem $record): void { + $user = auth()->user(); + $record->publish($user instanceof Model ? $user : null); + }), + Action::make('reject') + ->label('Reject') + ->icon('heroicon-o-x-mark') + ->color('danger') + ->visible(fn (LibraryItem $record): bool => static::gatekeeperCanReview() && $record->publication_status !== LibraryPublicationStatus::Rejected) + ->schema([ + Textarea::make('reason') + ->label('Reason') + ->rows(3), + ]) + ->action(function (LibraryItem $record, array $data): void { + $user = auth()->user(); + $reason = $data['reason'] ?? null; + $record->reject( + $user instanceof Model ? $user : null, + is_string($reason) ? $reason : null, + ); + }), + Action::make('return_to_draft') + ->label('Return') + ->icon('heroicon-o-arrow-uturn-left') + ->visible(fn (LibraryItem $record): bool => static::gatekeeperCanReview() && $record->publication_status === LibraryPublicationStatus::Pending) + ->action(function (LibraryItem $record): void { + $user = auth()->user(); + $record->returnToDraft($user instanceof Model ? $user : null); + }), Action::make('view') ->label('View') ->icon('heroicon-o-eye') @@ -389,6 +508,7 @@ public static function getPages(): array 'favorites' => Pages\Favorites::route('/favorites'), 'public' => Pages\PublicLibrary::route('/public'), 'search-all' => Pages\SearchAll::route('/search-all'), + 'gatekeeper-queue' => Pages\GatekeeperQueue::route('/gatekeeper-queue'), 'create-folder' => Pages\CreateFolder::route('/create-folder'), 'create-file' => Pages\CreateFile::route('/create-file'), 'create-link' => Pages\CreateLink::route('/create-link'), @@ -428,6 +548,11 @@ protected static function bulkManagePermissionsActions(): array ]; } + public static function gatekeeperCanReview(): bool + { + return FilamentLibraryPlugin::isLibraryAdmin(auth()->user()); + } + public static function getEditUrl($record): string { return match ($record->type) { diff --git a/src/Resources/Pages/EditFile.php b/src/Resources/Pages/EditFile.php index 5b039d5..2c71f0a 100644 --- a/src/Resources/Pages/EditFile.php +++ b/src/Resources/Pages/EditFile.php @@ -52,6 +52,8 @@ public function form(Schema $schema): Schema ->label('Description') ->rows(3), + ...LibraryItemResource::firmMetadataComponents(includeProjectTags: false), + Select::make('tags') ->label('Tags') ->relationship('tags', 'name') diff --git a/src/Resources/Pages/EditFolder.php b/src/Resources/Pages/EditFolder.php index e2442db..3434a6a 100644 --- a/src/Resources/Pages/EditFolder.php +++ b/src/Resources/Pages/EditFolder.php @@ -34,6 +34,8 @@ public function form(Schema $schema): Schema ->label('Description') ->rows(3), + ...LibraryItemResource::firmMetadataComponents(includeProjectTags: false), + Select::make('tags') ->label('Tags') ->relationship('tags', 'name') diff --git a/src/Resources/Pages/EditLink.php b/src/Resources/Pages/EditLink.php index 253cb76..07fc55b 100644 --- a/src/Resources/Pages/EditLink.php +++ b/src/Resources/Pages/EditLink.php @@ -39,6 +39,8 @@ public function form(Schema $schema): Schema ->label('Description') ->rows(3), + ...LibraryItemResource::firmMetadataComponents(includeProjectTags: false), + Select::make('tags') ->label('Tags') ->relationship('tags', 'name') diff --git a/src/Resources/Pages/GatekeeperQueue.php b/src/Resources/Pages/GatekeeperQueue.php new file mode 100644 index 0000000..0744131 --- /dev/null +++ b/src/Resources/Pages/GatekeeperQueue.php @@ -0,0 +1,46 @@ +user(); + + if (! $query instanceof Builder || ! $user || ! FilamentLibraryPlugin::isLibraryAdmin($user)) { + return LibraryItem::query()->whereRaw('1 = 0'); + } + + return LibraryItem::limitToUnpublished($query); + } + + public function getTitle(): string + { + return 'Gatekeeper Queue'; + } + + public function getSubheading(): ?string + { + return 'Draft, pending, and rejected items stay out of search until a gatekeeper publishes them.'; + } + + public function getBreadcrumbs(): array + { + return [ + static::getResource()::getUrl() => 'Library', + '' => 'Gatekeeper Queue', + ]; + } +} diff --git a/src/Resources/Pages/ListLibraryItems.php b/src/Resources/Pages/ListLibraryItems.php index f2d845d..e010774 100644 --- a/src/Resources/Pages/ListLibraryItems.php +++ b/src/Resources/Pages/ListLibraryItems.php @@ -263,7 +263,11 @@ protected function getTableQuery(): Builder })->where('name', 'not like', "%'s Personal Folder"); } - return $query; + if (! $query instanceof Builder) { + return LibraryItem::query()->whereRaw('1 = 0'); + } + + return LibraryItem::limitToPublished($query); } public function getTitle(): string diff --git a/src/Resources/Pages/PublicLibrary.php b/src/Resources/Pages/PublicLibrary.php index 1227a00..e8731ef 100644 --- a/src/Resources/Pages/PublicLibrary.php +++ b/src/Resources/Pages/PublicLibrary.php @@ -5,6 +5,7 @@ use Filament\Resources\Pages\ListRecords; use Illuminate\Database\Eloquent\Builder; use Tapp\FilamentLibrary\FilamentLibraryPlugin; +use Tapp\FilamentLibrary\Models\LibraryItem; use Tapp\FilamentLibrary\Resources\LibraryItemResource; class PublicLibrary extends ListRecords @@ -41,7 +42,11 @@ protected function getTableQuery(): Builder ->whereNotNull('personal_folder_id'); }); // Exclude personal folders - return $query; + if (! $query instanceof Builder) { + return LibraryItem::query()->whereRaw('1 = 0'); + } + + return LibraryItem::limitToPublished($query); } public function getTitle(): string diff --git a/src/Resources/Pages/SearchAll.php b/src/Resources/Pages/SearchAll.php index 219c14e..a2063f6 100644 --- a/src/Resources/Pages/SearchAll.php +++ b/src/Resources/Pages/SearchAll.php @@ -4,6 +4,7 @@ use Filament\Resources\Pages\ListRecords; use Illuminate\Database\Eloquent\Builder; +use Tapp\FilamentLibrary\Models\LibraryItem; use Tapp\FilamentLibrary\Resources\LibraryItemResource; class SearchAll extends ListRecords @@ -40,7 +41,11 @@ protected function getTableQuery(): Builder ->where('name', 'not like', "%'s Personal Folder"); } - return $query; + if (! $query instanceof Builder) { + return LibraryItem::query()->whereRaw('1 = 0'); + } + + return LibraryItem::limitToPublished($query); } public function getTitle(): string diff --git a/src/Services/LibraryImportResult.php b/src/Services/LibraryImportResult.php new file mode 100644 index 0000000..e7dbc0c --- /dev/null +++ b/src/Services/LibraryImportResult.php @@ -0,0 +1,27 @@ + $items + * @param list $errors + */ + public function __construct( + public array $items = [], + public array $errors = [], + ) {} + + public function importedCount(): int + { + return count($this->items); + } + + public function succeeded(): bool + { + return $this->errors === []; + } +} diff --git a/src/Services/LibraryImporter.php b/src/Services/LibraryImporter.php new file mode 100644 index 0000000..ba864ab --- /dev/null +++ b/src/Services/LibraryImporter.php @@ -0,0 +1,251 @@ +headerMap($header); + + if (! isset($map['name'])) { + fclose($handle); + + return new LibraryImportResult(items: [], errors: ['Manifest must include a name column.']); + } + + $items = []; + $errors = []; + $line = 1; + + while (($row = fgetcsv($handle, 0, ',', '"', '')) !== false) { + $line++; + + if ($this->rowIsEmpty($row)) { + continue; + } + + $record = []; + + foreach ($map as $field => $index) { + $record[$field] = $row[$index] ?? null; + } + + try { + $items[] = $this->importRow($record, $userId, $defaultStatus); + } catch (InvalidArgumentException $exception) { + $errors[] = "Row {$line}: {$exception->getMessage()}"; + } + } + + fclose($handle); + + return new LibraryImportResult($items, $errors); + } + + /** + * Create one library item from a manifest row. + * + * The item is always draft or pending. A published value in the row is ignored. + * + * @param array $row + */ + public function importRow(array $row, int $userId, LibraryPublicationStatus $defaultStatus = LibraryPublicationStatus::Pending): LibraryItem + { + $name = trim((string) ($row['name'] ?? '')); + + if ($name === '') { + throw new InvalidArgumentException('Name is required.'); + } + + $type = strtolower(trim((string) ($row['type'] ?? 'file'))); + + if ($type === '') { + $type = 'file'; + } + + if (! in_array($type, ['file', 'folder', 'link'], true)) { + throw new InvalidArgumentException("Type [{$type}] is not supported."); + } + + $libraryArea = $this->nullableString($row['library_area'] ?? $row['folder'] ?? null); + $committee = $this->nullableString($row['committee'] ?? null); + $this->assertCommitteeIsAllowed($committee); + + $status = $this->resolveImportStatus( + isset($row['publication_status']) ? (string) $row['publication_status'] : null, + $defaultStatus, + ); + + $model = FilamentLibraryPlugin::libraryItemModelClass(); + $parentId = null; + + if ($libraryArea !== null && ! ($type === 'folder' && $name === $libraryArea)) { + $parentId = $this->areaFolderId($model, $libraryArea, $userId); + } + + /** @var LibraryItem $item */ + $item = $model::query()->create([ + 'name' => $name, + 'type' => $type, + 'parent_id' => $parentId, + 'created_by' => $userId, + 'updated_by' => $userId, + 'general_access' => 'private', + 'external_url' => $type === 'link' ? $this->nullableString($row['external_url'] ?? null) : null, + 'firm_path' => $this->nullableString($row['firm_path'] ?? null), + 'library_area' => $libraryArea, + 'committee' => $committee, + 'project_tags' => LibraryItem::normalizeProjectTags($row['project_tags'] ?? null), + 'publication_status' => $status, + ]); + + return $item->refresh(); + } + + public function resolveImportStatus(?string $requested, LibraryPublicationStatus $default): LibraryPublicationStatus + { + if (! in_array($default, [LibraryPublicationStatus::Draft, LibraryPublicationStatus::Pending], true)) { + $default = LibraryPublicationStatus::Pending; + } + + return match (strtolower(trim((string) $requested))) { + '' => $default, + 'draft' => LibraryPublicationStatus::Draft, + 'pending' => LibraryPublicationStatus::Pending, + default => $default, + }; + } + + /** + * @param list $header + * @return array + */ + protected function headerMap(array $header): array + { + $aliases = [ + 'name' => ['name', 'title'], + 'firm_path' => ['firm_path', 'firm path', 'path'], + 'library_area' => ['library_area', 'library area', 'folder', 'area'], + 'committee' => ['committee'], + 'project_tags' => ['project_tags', 'project tags', 'tags'], + 'type' => ['type'], + 'external_url' => ['external_url', 'url'], + 'publication_status' => ['publication_status', 'status'], + ]; + + $normalized = []; + + foreach ($header as $index => $column) { + $normalized[strtolower(trim((string) $column))] = $index; + } + + $map = []; + + foreach ($aliases as $field => $names) { + foreach ($names as $name) { + if (array_key_exists($name, $normalized)) { + $map[$field] = $normalized[$name]; + + break; + } + } + } + + return $map; + } + + /** + * @param list $row + */ + protected function rowIsEmpty(array $row): bool + { + foreach ($row as $value) { + if (trim((string) $value) !== '') { + return false; + } + } + + return true; + } + + protected function nullableString(mixed $value): ?string + { + $value = trim((string) $value); + + return $value === '' ? null : $value; + } + + protected function assertCommitteeIsAllowed(?string $committee): void + { + if ($committee === null) { + return; + } + + $allowed = config('filament-library.publication.committees', []); + + if (! is_array($allowed) || $allowed === []) { + return; + } + + if (! in_array($committee, $allowed, true)) { + throw new InvalidArgumentException("Committee [{$committee}] is not configured."); + } + } + + /** + * @param class-string $model + */ + protected function areaFolderId(string $model, string $libraryArea, int $userId): int + { + $folder = $model::query() + ->where('type', 'folder') + ->where('library_area', $libraryArea) + ->whereNull('parent_id') + ->first(); + + if ($folder) { + return (int) $folder->getKey(); + } + + $folder = $model::query()->create([ + 'name' => $libraryArea, + 'type' => 'folder', + 'parent_id' => null, + 'created_by' => $userId, + 'updated_by' => $userId, + 'general_access' => 'private', + 'library_area' => $libraryArea, + 'publication_status' => LibraryPublicationStatus::Published, + ]); + + return (int) $folder->getKey(); + } +} diff --git a/tests/Unit/LibraryPublicationTest.php b/tests/Unit/LibraryPublicationTest.php new file mode 100644 index 0000000..8316362 --- /dev/null +++ b/tests/Unit/LibraryPublicationTest.php @@ -0,0 +1,268 @@ +insertGetId([ + 'name' => 'Librarian', + 'email' => 'librarian-' . uniqid('', true) . '@example.com', + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +test('ordinary creates stay published and searchable', function (): void { + $userId = createLibraryUser(); + + $item = LibraryItem::query()->create([ + 'name' => 'Existing standard', + 'type' => 'file', + 'created_by' => $userId, + 'updated_by' => $userId, + 'general_access' => 'private', + ]); + + expect($item->publication_status)->toBe(LibraryPublicationStatus::Published) + ->and($item->isSearchable())->toBeTrue() + ->and(LibraryItem::query()->searchable()->pluck('id'))->toContain($item->id); +}); + +test('new items wait for approval when the host turns that on', function (): void { + config()->set('filament-library.publication.new_items_require_approval', true); + $userId = createLibraryUser(); + + $item = LibraryItem::query()->create([ + 'name' => 'Waiting upload', + 'type' => 'file', + 'created_by' => $userId, + 'updated_by' => $userId, + 'general_access' => 'anyone_can_view', + ]); + + expect($item->publication_status)->toBe(LibraryPublicationStatus::Pending) + ->and(LibraryItem::query()->searchable()->pluck('id'))->not->toContain($item->id); +}); + +test('imports land pending with firm metadata and stay out of search', function (): void { + Event::fake([LibraryItemPublished::class, LibraryItemRejected::class]); + $userId = createLibraryUser(); + + $item = app(LibraryImporter::class)->importRow([ + 'name' => 'Title Block.dwg', + 'firm_path' => '\\s9\concept\title-block.dwg', + 'library_area' => 'Concept', + 'committee' => 'Design', + 'project_tags' => 'pilot|title-block', + 'type' => 'file', + 'publication_status' => 'published', + ], $userId); + + $item->load('tags', 'parent'); + + expect($item->publication_status)->toBe(LibraryPublicationStatus::Pending) + ->and($item->firm_path)->toBe('\\s9\concept\title-block.dwg') + ->and($item->library_area)->toBe('Concept') + ->and($item->committee)->toBe('Design') + ->and($item->project_tags)->toBe(['pilot', 'title-block']) + ->and($item->parent?->type)->toBe('folder') + ->and($item->parent?->name)->toBe('Concept') + ->and($item->parent?->publication_status)->toBe(LibraryPublicationStatus::Published) + ->and($item->tags->pluck('name')->sort()->values()->all())->toBe(['pilot', 'title-block']) + ->and($item->isSearchable())->toBeFalse() + ->and(LibraryItem::query()->searchable()->pluck('id'))->not->toContain($item->id) + ->and(LibraryItem::query()->unpublished()->pluck('id'))->toContain($item->id) + ->and(LibraryItem::query()->withProjectTag('pilot')->pluck('id'))->toContain($item->id) + ->and(LibraryItem::query()->inLibraryArea('Concept')->pluck('id'))->toContain($item->id) + ->and(LibraryItem::query()->forCommittee('Design')->pluck('id'))->toContain($item->id); + + Event::assertNotDispatched(LibraryItemPublished::class); +}); + +test('a manifest can land rows as draft', function (): void { + $userId = createLibraryUser(); + + $item = app(LibraryImporter::class)->importRow([ + 'name' => 'Draft detail', + 'publication_status' => 'draft', + ], $userId, LibraryPublicationStatus::Pending); + + expect($item->publication_status)->toBe(LibraryPublicationStatus::Draft) + ->and(LibraryItem::query()->searchable()->pluck('id'))->not->toContain($item->id); +}); + +test('publishing makes an item searchable and rejecting removes it again', function (): void { + Event::fake([LibraryItemPublished::class, LibraryItemRejected::class]); + $userId = createLibraryUser(); + $item = app(LibraryImporter::class)->importRow([ + 'name' => 'Door schedule', + 'firm_path' => '\\s9\technical\door-schedule.xlsx', + 'library_area' => 'Technical', + 'committee' => 'Technical', + 'project_tags' => ['doors'], + ], $userId); + + $item->publish($userId); + $item->refresh(); + + expect($item->publication_status)->toBe(LibraryPublicationStatus::Published) + ->and($item->published_by)->toBe($userId) + ->and($item->isSearchable())->toBeTrue() + ->and(LibraryItem::query()->searchable()->pluck('id'))->toContain($item->id); + + Event::assertDispatched(LibraryItemPublished::class); + + $item->publish($userId); + Event::assertDispatchedTimes(LibraryItemPublished::class, 1); + + $item->reject($userId, 'Needs a companion PDF'); + $item->refresh(); + + expect($item->publication_status)->toBe(LibraryPublicationStatus::Rejected) + ->and($item->rejection_reason)->toBe('Needs a companion PDF') + ->and($item->published_at)->toBeNull() + ->and(LibraryItem::query()->searchable()->pluck('id'))->not->toContain($item->id); + + Event::assertDispatched(LibraryItemRejected::class, function (LibraryItemRejected $event) use ($item): bool { + return $event->libraryItem->is($item) && $event->reason === 'Needs a companion PDF'; + }); +}); + +test('returning a pending item to draft keeps it out of search', function (): void { + $userId = createLibraryUser(); + $item = app(LibraryImporter::class)->importRow(['name' => 'Returned sheet'], $userId); + + $item->returnToDraft($userId); + $item->refresh(); + + expect($item->publication_status)->toBe(LibraryPublicationStatus::Draft) + ->and(LibraryItem::query()->searchable()->pluck('id'))->not->toContain($item->id); +}); + +test('a csv manifest imports valid rows and reports invalid ones', function (): void { + $userId = createLibraryUser(); + config()->set('filament-library.publication.committees', ['Design', 'Technical']); + + $path = tempnam(sys_get_temp_dir(), 'library-manifest'); + file_put_contents($path, <<<'CSV' +name,firm_path,folder,committee,project_tags,type,status +Concept cover.pdf,\\s9\concept\cover.pdf,Concept,Design,pilot;cover,file,published +,\\s9\missing-name.pdf,Concept,Design,pilot,file,pending +Finance notes.pdf,\\s9\finance\notes.pdf,Concept,Finance,pilot,file,pending +CSV); + + try { + $result = app(LibraryImporter::class)->importCsv($path, $userId); + } finally { + unlink($path); + } + + expect($result->importedCount())->toBe(1) + ->and($result->succeeded())->toBeFalse() + ->and($result->errors)->toHaveCount(2) + ->and($result->items[0]->publication_status)->toBe(LibraryPublicationStatus::Pending) + ->and($result->items[0]->library_area)->toBe('Concept') + ->and($result->items[0]->committee)->toBe('Design') + ->and($result->items[0]->firm_path)->toBe('\\\\s9\\concept\\cover.pdf') + ->and($result->items[0]->project_tags)->toBe(['pilot', 'cover']) + ->and(LibraryItem::query()->searchable()->where('name', 'Concept cover.pdf')->exists())->toBeFalse() + ->and(LibraryItem::query()->where('name', 'Finance notes.pdf')->exists())->toBeFalse(); +}); + +test('import and review commands publish only through the gatekeeper', function (): void { + $userId = createLibraryUser(); + $path = tempnam(sys_get_temp_dir(), 'library-manifest'); + file_put_contents($path, <<<'CSV' +name,firm_path,library_area,committee,project_tags +Wall section.pdf,\\s9\technical\wall-section.pdf,Technical,Technical,walls +CSV); + + try { + $this->artisan('filament-library:import', [ + 'path' => $path, + '--user' => $userId, + '--status' => 'draft', + ])->assertSuccessful(); + } finally { + unlink($path); + } + + $item = LibraryItem::query()->where('name', 'Wall section.pdf')->first(); + + expect($item)->not->toBeNull() + ->and($item->publication_status)->toBe(LibraryPublicationStatus::Draft) + ->and(LibraryItem::query()->searchable()->pluck('id'))->not->toContain($item->id); + + $this->artisan('filament-library:review', [ + 'id' => $item->id, + 'action' => 'publish', + '--user' => $userId, + ])->assertSuccessful(); + + $item->refresh(); + + expect($item->publication_status)->toBe(LibraryPublicationStatus::Published) + ->and(LibraryItem::query()->searchable()->pluck('id'))->toContain($item->id); + + $this->artisan('filament-library:review', [ + 'id' => $item->id, + 'action' => 'reject', + '--user' => $userId, + '--reason' => 'Hold for committee', + ])->assertSuccessful(); + + $item->refresh(); + + expect($item->publication_status)->toBe(LibraryPublicationStatus::Rejected) + ->and(LibraryItem::query()->searchable()->pluck('id'))->not->toContain($item->id); +}); + +test('import refuses a published status flag', function (): void { + $userId = createLibraryUser(); + $path = tempnam(sys_get_temp_dir(), 'library-manifest'); + file_put_contents($path, "name\nShould not import\n"); + + try { + $this->artisan('filament-library:import', [ + 'path' => $path, + '--user' => $userId, + '--status' => 'published', + ])->assertFailed(); + } finally { + unlink($path); + } + + expect(LibraryItem::query()->where('name', 'Should not import')->exists())->toBeFalse(); +}); + +test('renaming an item keeps project tags that were stored on import', function (): void { + $userId = createLibraryUser(); + $item = app(LibraryImporter::class)->importRow([ + 'name' => 'Tagged block', + 'project_tags' => 'alpha, beta', + ], $userId); + + $item->update(['name' => 'Tagged block renamed']); + $item->refresh()->load('tags'); + + expect($item->project_tags)->toBe(['alpha', 'beta']) + ->and($item->tags->pluck('name')->sort()->values()->all())->toBe(['alpha', 'beta']) + ->and(LibraryItemTag::query()->whereIn('name', ['alpha', 'beta'])->count())->toBe(2); +}); + +test('firm metadata fields are available on the library form', function (): void { + $names = array_map( + fn ($component) => $component->getName(), + LibraryItemResource::firmMetadataComponents(), + ); + + expect($names)->toBe(['firm_path', 'library_area', 'committee', 'project_tags']); +});