Feat: Updating FIle Services, Updating Order Views to list product extra options

This commit is contained in:
2026-09-25 10:08:57 +03:00
parent 2b8fe5764c
commit 6025ea4304
10 changed files with 332 additions and 13 deletions
+5
View File
@@ -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);
}
}
+9 -1
View File
@@ -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;
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\Core\File\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Core\File\Models\File;
use Modules\Core\File\Services\FileService;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Streams a File's bytes straight to the browser — inline by default (a
* browser-previewable type like an image opens/displays directly), or as
* a forced download with ?download=1 (e.g. an explicit "Download" button
* distinct from a thumbnail/preview link pointing at the same file). Only
* reachable via a short-lived signed URL — same auth model as
* Modules\Core\Shipping\Http\Controllers\DownloadShipmentLabelController
* (a valid signature IS the auth check, no separate staff/customer
* session check here) — so any caller that can mint a signed URL to this
* route (the storefront's own custom-field upload flow, or the admin
* order-line display) can hand a viewer a working link without this
* controller knowing anything about who they are or why they're allowed
* to see this particular file.
*
* Looks the File up manually from a plain {file} id rather than relying
* on implicit route-model-binding — registered via loadRoutesFrom() with
* no middleware group (see Providers\FileServiceProvider::boot()), so
* SubstituteBindings never runs and a type-hinted File parameter would
* silently resolve to an empty, non-existent model instead of 404ing.
*/
class DownloadFileController extends Controller
{
public function __invoke(Request $request, int $file, FileService $files): StreamedResponse
{
if (! $request->hasValidSignature()) {
abort(401);
}
$file = File::findOrFail($file);
abort_unless($files->exists($file), 404);
return $request->boolean('download') ? $files->download($file) : $files->retrieve($file);
}
}
@@ -0,0 +1,44 @@
<?php
namespace Modules\Core\File\Listeners;
use Modules\Core\Cart\Events\CartLineAdded;
use Modules\Core\File\Models\File;
use Modules\Core\File\Services\FileService;
/**
* A custom-field photo is uploaded (and gets its own File row, unowned)
* the moment a shopper picks it on the product page — before add-to-cart
* even runs (see 3dealer's CustomFieldUploadController). The storefront's
* add-to-cart request only carries that File's `id` in its custom_fields
* answer (see CartController::customFieldsMeta()); this is what actually
* gives the File an owner, once the real CartLine it belongs to exists.
*
* Listens for Cart\Events\CartLineAdded rather than reaching back into
* the cart after CartService::addLine() returns — that event already
* carries the exact CartLine Lunar resolved/created, with no need to
* re-match it by meta (ambiguous whenever two lines share a purchasable +
* similar meta).
*/
class AttachCustomFieldFileToCartLine
{
public function __construct(
private readonly FileService $files,
) {}
public function handle(CartLineAdded $event): void
{
$fileIds = collect($event->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));
}
}
@@ -0,0 +1,62 @@
<?php
namespace Modules\Core\File\Listeners;
use Lunar\Models\OrderLine;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\File\Models\File;
use Modules\Core\File\Services\FileService;
/**
* Lunar\Pipelines\Order\Creation\CreateOrderLines copies a CartLine's
* meta (custom_fields included) onto its new OrderLine verbatim — so an
* order's custom-field file answer keeps the exact same `file_id` its
* originating cart line's meta already had (see 3dealer's CartController::
* customFieldsMeta(), which stores only that id — File is the single
* source of truth for disk/path/name/mime, never duplicated into meta).
* That id is enough to find the File row directly, with no need to match
* an OrderLine back to "the" CartLine it came from.
*
* Re-points ownership (not a copy — the same File row) from whatever
* CartLine owned it to this OrderLine, so a customer's placed order keeps
* its file even after the cart it came from is later cleared (see
* Modules\Core\Cart\Services\CartService, or a checkout-complete cart
* reset) — FileService::pruneUnowned() only ever removes UNOWNED files,
* but a File left pointing at a since-deleted CartLine would be just as
* orphaned in practice; this listener is what keeps that from ever
* happening for a real, placed order.
*
* Listens for Checkout\Events\OrderPlaced, not an OrderLine model event —
* that's the one place in this codebase an order is reliably known to be
* placed exactly once (see that event's own docblock), and it hands over
* the whole Order with every line already loaded.
*/
class TransferCustomFieldFileOwnership
{
public function __construct(
private readonly FileService $files,
) {}
public function handle(OrderPlaced $event): void
{
foreach ($event->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));
}
}
+18 -2
View File
@@ -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);
+7
View File
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Core\File\Http\Controllers\DownloadFileController;
Route::get('files/{file}/download', DownloadFileController::class)
->name('files.download');