diff --git a/database/migrations/2026_09_25_000001_create_files_table.php b/database/migrations/2026_09_25_000001_create_files_table.php index 9ebefa3..fc032cb 100644 --- a/database/migrations/2026_09_25_000001_create_files_table.php +++ b/database/migrations/2026_09_25_000001_create_files_table.php @@ -5,7 +5,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; /** - * A generic, storage-backend-agnostic file registry — Modules\Core\Files\ + * A generic, storage-backend-agnostic file registry — Modules\Core\File\ * Services\FileService's own backing table. `disk`/`path` are whatever * Laravel's Storage facade already understands (local, s3, ...); this * table adds what Flysystem itself has no concept of: who a file @@ -28,7 +28,7 @@ return new class extends Migration { public function up(): void { - Schema::create('file_uploads', function (Blueprint $table) { + Schema::create('files', function (Blueprint $table) { $table->id(); $table->string('disk'); $table->string('path'); @@ -45,6 +45,6 @@ return new class extends Migration public function down(): void { - Schema::dropIfExists('file_uploads'); + Schema::dropIfExists('files'); } }; diff --git a/src/File/Adapters/LocalFileAdapter.php b/src/File/Adapters/LocalFileAdapter.php index 9e78109..86b1b7b 100644 --- a/src/File/Adapters/LocalFileAdapter.php +++ b/src/File/Adapters/LocalFileAdapter.php @@ -40,4 +40,9 @@ class LocalFileAdapter implements FileAdapterInterface { return $this->disk->response($path, $name); } + + public function download(string $path, ?string $name = null): StreamedResponse + { + return $this->disk->download($path, $name); + } } diff --git a/src/File/Contracts/FileAdapterInterface.php b/src/File/Contracts/FileAdapterInterface.php index eb1740d..252f066 100644 --- a/src/File/Contracts/FileAdapterInterface.php +++ b/src/File/Contracts/FileAdapterInterface.php @@ -32,7 +32,15 @@ interface FileAdapterInterface public function delete(string $path): void; /** - * Streams the file at $path straight to the browser. + * Streams the file at $path straight to the browser, inline (the + * browser renders/previews it directly rather than prompting to save). */ public function retrieve(string $path, ?string $name = null): StreamedResponse; + + /** + * Same bytes as retrieve(), but as a forced attachment — the browser + * always prompts to save, even for a type it could otherwise preview + * (an image inline in a new tab). + */ + public function download(string $path, ?string $name = null): StreamedResponse; } diff --git a/src/File/Http/Controllers/DownloadFileController.php b/src/File/Http/Controllers/DownloadFileController.php new file mode 100644 index 0000000..8ce846c --- /dev/null +++ b/src/File/Http/Controllers/DownloadFileController.php @@ -0,0 +1,45 @@ +hasValidSignature()) { + abort(401); + } + + $file = File::findOrFail($file); + + abort_unless($files->exists($file), 404); + + return $request->boolean('download') ? $files->download($file) : $files->retrieve($file); + } +} diff --git a/src/File/Listeners/AttachCustomFieldFileToCartLine.php b/src/File/Listeners/AttachCustomFieldFileToCartLine.php new file mode 100644 index 0000000..9557b5f --- /dev/null +++ b/src/File/Listeners/AttachCustomFieldFileToCartLine.php @@ -0,0 +1,44 @@ +line->meta['custom_fields'] ?? []) + ->pluck('file_id') + ->filter() + ->all(); + + if ($fileIds === []) { + return; + } + + File::query() + ->whereIn('id', $fileIds) + ->each(fn (File $file) => $this->files->attachOwner($file, $event->line)); + } +} diff --git a/src/File/Listeners/TransferCustomFieldFileOwnership.php b/src/File/Listeners/TransferCustomFieldFileOwnership.php new file mode 100644 index 0000000..0cbb924 --- /dev/null +++ b/src/File/Listeners/TransferCustomFieldFileOwnership.php @@ -0,0 +1,62 @@ +order->lines as $line) { + $this->transferLine($line); + } + } + + private function transferLine(OrderLine $line): void + { + $fileIds = collect($line->meta['custom_fields'] ?? []) + ->pluck('file_id') + ->filter() + ->all(); + + if ($fileIds === []) { + return; + } + + File::query() + ->whereIn('id', $fileIds) + ->each(fn (File $file) => $this->files->attachOwner($file, $line)); + } +} diff --git a/src/File/Services/FileService.php b/src/File/Services/FileService.php index daee2c2..4c08180 100644 --- a/src/File/Services/FileService.php +++ b/src/File/Services/FileService.php @@ -20,6 +20,12 @@ use Symfony\Component\HttpFoundation\StreamedResponse; * (3dealer's custom-field upload flow today, some other future * file-upload need tomorrow) supplies its own `purpose` string and owner * model, and scopes its own queries by them. + * + * `purpose` also doubles as the storage directory a file lands under + * (store() passes it straight through as the adapter's own $directory) — + * one string to name both, rather than every caller supplying two + * near-identical values for what's really the same distinction ("which + * kind of upload is this"). */ class FileService { @@ -27,9 +33,9 @@ class FileService private readonly Container $container, ) {} - public function store(UploadedFile $file, string $purpose, string $directory, string $disk = 'local'): File + public function store(UploadedFile $file, string $purpose, string $disk = 'local'): File { - $path = $this->adapter($disk)->store($file, $directory); + $path = $this->adapter($disk)->store($file, $purpose); return File::create([ 'disk' => $disk, @@ -62,6 +68,16 @@ class FileService return $this->adapter($file->disk)->retrieve($file->path, $file->original_name); } + /** + * Same file as retrieve(), forced as a download (Content-Disposition: + * attachment) rather than served inline — for a button distinct from + * a preview link/thumbnail pointing at the same File. + */ + public function download(File $file): StreamedResponse + { + return $this->adapter($file->disk)->download($file->path, $file->original_name); + } + public function exists(File $file): bool { return $this->adapter($file->disk)->exists($file->path); diff --git a/src/File/routes/web.php b/src/File/routes/web.php new file mode 100644 index 0000000..4c1c96a --- /dev/null +++ b/src/File/routes/web.php @@ -0,0 +1,7 @@ +name('files.download'); diff --git a/src/Order/Filament/Extensions/OrderItemsTableExtension.php b/src/Order/Filament/Extensions/OrderItemsTableExtension.php index 7a8bcc8..15c4dbb 100644 --- a/src/Order/Filament/Extensions/OrderItemsTableExtension.php +++ b/src/Order/Filament/Extensions/OrderItemsTableExtension.php @@ -3,22 +3,60 @@ namespace Modules\Core\Order\Filament\Extensions; use Filament\Actions\BulkAction; +use Filament\Support\Colors\Color; use Filament\Support\Exceptions\Halt; +use Filament\Tables\Columns\Layout\Panel; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\URL; +use Illuminate\Support\HtmlString; use Lunar\Admin\Support\Extending\BaseExtension; +use Lunar\Models\OrderLine; +use Modules\Core\File\Models\File; /** - * Same fix as OrderActionsExtension, applied to the order lines - * table's "bulk_refund" toolbar action (Lunar\Admin\...\OrderItemsTable:: - * getBulkRefundAction()) — see that class's docblock for the underlying - * Filament bug (failureNotification()+failure()+halt() never actually - * sends the notification, because halt()'s Halt exception is caught before - * Filament reaches the code that would send it). + * extendTable() has two unrelated jobs: the "bulk_refund" toolbar-action + * fix (see fixFailureNotification()'s own docblock — a genuine Filament + * bug), and adding a "Custom Fields" entry to each order line's own + * collapsible details dropdown (Lunar\Admin\...\OrderItemsTable:: + * getOrderLinesTableColumns()'s Panel — the same one already showing + * stock level, notes, and the price_breakdowns table) — the shopper's + * answers to Product::$custom_fields (a reference photo, personalization + * text, ...), stored on OrderLine.meta by 3dealer's CartController:: + * customFieldsMeta() and, until now, never shown anywhere in the admin. + * + * Finds that Panel via $table->getCollapsibleColumnsLayout() — NOT + * $table->getColumns(), which two earlier attempts at this both reached + * for. HasColumns::pushColumns() flattens every Panel/Split into leaf + * columns at table-build time and stores THAT flat list as + * $this->columns (what getColumns() returns); the original nested + * Panel/Stack objects actually used for rendering are kept separately — + * in $this->columnsLayout for a non-collapsible layout component, or + * $this->collapsibleColumnsLayout for one that IS collapsible (this + * order-lines Panel is, via ->collapsible()). So `$column instanceof + * Panel` over getColumns() can never match anything — Panel/Split + * instances simply never appear in that array at all — and a fix built + * on that check silently mutated nothing. A first attempt building a + * brand new Panel and re-calling $table->columns() on top of the + * existing setup fixed nothing either and instead rendered as a stray + * empty extra column outside the dropdown (caught by actually opening + * the order page). Mutates the found Panel's Stack in place via + * Stack::schema(), the one part of both earlier attempts that actually + * worked once the right object was found. + * + * Its own TextColumn rather than reusing the Panel's existing KeyValue: + * KeyValue's own Blade view HTML-escapes every value ({{ $value }}), + * which can't render a clickable link for a file answer. */ class OrderItemsTableExtension extends BaseExtension { public function extendTable(Table $table): Table { + if ($table->getCollapsibleColumnsLayout() instanceof Panel) { + $this->addCustomFieldsColumn($table->getCollapsibleColumnsLayout()); + } + return $table->toolbarActions( array_map( fn ($action) => $action instanceof BulkAction && $action->getName() === 'bulk_refund' @@ -29,6 +67,88 @@ class OrderItemsTableExtension extends BaseExtension ); } + private function addCustomFieldsColumn(Panel $panel): void + { + $stack = $panel->getComponents()[0] ?? null; + + if ($stack === null) { + return; + } + + $stack->schema([ + ...$stack->getComponents(), + TextColumn::make('custom_fields') + ->label('Custom Fields') + ->visible(fn (OrderLine $record) => filled($record->meta['custom_fields'] ?? null)) + ->getStateUsing(fn (OrderLine $record) => $this->renderCustomFields($record)) + ->html(), + ]); + } + + /** + * Same table markup/classes as this Panel's own existing KeyValue + * component (Lunar\Admin's price_breakdowns, right above this in the + * dropdown — see lunarpanel::tables.components.key-value) for visual + * consistency, rebuilt here rather than reused: KeyValue's Blade view + * HTML-escapes every value ({{ $value }}), which can't render a + * thumbnail/download link for a file answer. + */ + private function renderCustomFields(OrderLine $record): HtmlString + { + $rows = collect($record->meta['custom_fields'] ?? []) + ->map(fn (array $field) => sprintf( + '%s%s', + e($field['label']), + $field['type'] === 'file' ? $this->fileCell($field) : e($field['value'] ?? ''), + )) + ->implode(''); + + return new HtmlString( + '
' + .'' + .$rows + .'
', + ); + } + + /** + * A thumbnail (previewable image types only — an inline-signed URL to + * the same File; see FileService::retrieve()) alongside an icon-only + * download link forcing Content-Disposition: attachment (FileService:: + * download()) — two separate signed URLs, not one reused with a query + * string appended after signing, since a signature covers the exact + * query parameters present when it was minted. + */ + private function fileCell(array $field): string + { + $file = File::find($field['file_id'] ?? null); + + if ($file === null) { + return __('lunarpanel::global.na'); + } + + $previewUrl = URL::temporarySignedRoute('files.download', now()->addHours(2), ['file' => $file->id]); + $downloadUrl = URL::temporarySignedRoute('files.download', now()->addHours(2), ['file' => $file->id, 'download' => 1]); + $previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + + $thumbnail = in_array($file->mime, $previewable, true) + ? sprintf( + '', + $previewUrl, + $previewUrl, + ) + : ''; + + return sprintf( + '
%s%s%s
', + $thumbnail, + e($file->original_name), + $downloadUrl, + Color::Blue[600], + Blade::render(''), + ); + } + private function fixFailureNotification(BulkAction $action): BulkAction { $originalAction = $action->getActionFunction(); diff --git a/src/Providers/FileServiceProvider.php b/src/Providers/FileServiceProvider.php index 531035d..3066924 100644 --- a/src/Providers/FileServiceProvider.php +++ b/src/Providers/FileServiceProvider.php @@ -2,11 +2,16 @@ namespace Modules\Core\Providers; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Storage; use Illuminate\Support\ServiceProvider; use InvalidArgumentException; +use Modules\Core\Cart\Events\CartLineAdded; +use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\File\Adapters\LocalFileAdapter; use Modules\Core\File\Contracts\FileAdapterInterface; +use Modules\Core\File\Listeners\AttachCustomFieldFileToCartLine; +use Modules\Core\File\Listeners\TransferCustomFieldFileOwnership; class FileServiceProvider extends ServiceProvider { @@ -29,6 +34,13 @@ class FileServiceProvider extends ServiceProvider public function boot(): void { - $this->loadMigrationsFrom(__DIR__.'/../../database/migrations'); + // Signed-URL auth only, same model as Shipping\Http\Controllers\ + // DownloadShipmentLabelController — see that route's own docblock. + // Migrations live in the shared database/migrations directory + // CoreServiceProvider already loads; nothing more to register here. + $this->loadRoutesFrom(__DIR__.'/../File/routes/web.php'); + + Event::listen(CartLineAdded::class, AttachCustomFieldFileToCartLine::class); + Event::listen(OrderPlaced::class, TransferCustomFieldFileOwnership::class); } }