Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions config/filament-library.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('library_items', function (Blueprint $table) {
$table->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',
]);
});
}
};
51 changes: 51 additions & 0 deletions src/Commands/ImportLibraryCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace Tapp\FilamentLibrary\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Tapp\FilamentLibrary\Enums\LibraryPublicationStatus;
use Tapp\FilamentLibrary\Services\LibraryImporter;

class ImportLibraryCommand extends Command
{
public $signature = 'filament-library:import
{path : CSV manifest path}
{--user= : User id recorded as the creator}
{--status=pending : draft or pending. Published values are ignored}';

public $description = 'Import a library manifest as draft or pending items';

public function handle(LibraryImporter $importer): int
{
$userId = (int) $this->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;
}
}
78 changes: 78 additions & 0 deletions src/Commands/ReviewLibraryItemCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace Tapp\FilamentLibrary\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Tapp\FilamentLibrary\FilamentLibraryPlugin;
use Tapp\FilamentLibrary\Models\LibraryItem;

class ReviewLibraryItemCommand extends Command
{
public $signature = 'filament-library:review
{id : Library item id}
{action : publish, reject, or return}
{--reason= : Note stored when rejecting}
{--user= : Gatekeeper user id}';

public $description = 'Publish, reject, or return a library item';

public function handle(): int
{
$model = FilamentLibraryPlugin::libraryItemModelClass();
$item = $model::query()->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;
}
}
40 changes: 40 additions & 0 deletions src/Enums/LibraryPublicationStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Tapp\FilamentLibrary\Enums;

enum LibraryPublicationStatus: string
{
case Draft = 'draft';
case Pending = 'pending';
case Published = 'published';
case Rejected = 'rejected';

public function isSearchable(): bool
{
return $this === self::Published;
}

public function label(): string
{
return match ($this) {
self::Draft => 'Draft',
self::Pending => 'Pending',
self::Published => 'Published',
self::Rejected => 'Rejected',
};
}

/**
* @return array<string, string>
*/
public static function options(): array
{
$options = [];

foreach (self::cases() as $status) {
$options[$status->value] = $status->label();
}

return $options;
}
}
21 changes: 21 additions & 0 deletions src/Events/LibraryItemPublished.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Tapp\FilamentLibrary\Events;

use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Tapp\FilamentLibrary\Models\LibraryItem;

/**
* Fired when a gatekeeper publishes a library item.
* Host applications may listen to update a search index. Drafts are not published by import.
*/
class LibraryItemPublished
{
use Dispatchable;
use SerializesModels;

public function __construct(
public LibraryItem $libraryItem,
) {}
}
22 changes: 22 additions & 0 deletions src/Events/LibraryItemRejected.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Tapp\FilamentLibrary\Events;

use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Tapp\FilamentLibrary\Models\LibraryItem;

/**
* Fired when a gatekeeper rejects a library item.
* Rejected items stay out of search. Host applications may listen to drop an index entry.
*/
class LibraryItemRejected
{
use Dispatchable;
use SerializesModels;

public function __construct(
public LibraryItem $libraryItem,
public ?string $reason = null,
) {}
}
Loading
Loading