feat: enhance PresignedUploadController to support local uploads and improve S3 integration; add localUpload method for handling uploads when S3 is not the default disk
Some checks failed
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
Yoga Pangestu 2026-07-02 22:53:28 +07:00
parent 4eea267756
commit 3d00511626
9 changed files with 126 additions and 44 deletions

View File

@ -6,6 +6,7 @@
use Aws\S3\S3Client;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class PresignedUploadController extends Controller
@ -20,23 +21,29 @@ public function presign(Request $request): JsonResponse
$extension = pathinfo($validated['filename'], PATHINFO_EXTENSION) ?: 'bin';
$key = 'temp/'.Str::uuid().'.'.$extension;
$s3 = new S3Client([
'region' => config('filesystems.disks.s3.region'),
'endpoint' => config('filesystems.disks.s3.endpoint'),
'use_path_style_endpoint' => config('filesystems.disks.s3.use_path_style_endpoint'),
'credentials' => [
'key' => config('filesystems.disks.s3.key'),
'secret' => config('filesystems.disks.s3.secret'),
],
]);
$defaultDisk = config('filesystems.default');
$command = $s3->getCommand('PutObject', [
'Bucket' => config('filesystems.disks.s3.bucket'),
'Key' => $key,
'ContentType' => $validated['mime_type'],
]);
if ($defaultDisk === 's3') {
$s3 = new S3Client([
'region' => config('filesystems.disks.s3.region'),
'endpoint' => config('filesystems.disks.s3.endpoint'),
'use_path_style_endpoint' => config('filesystems.disks.s3.use_path_style_endpoint'),
'credentials' => [
'key' => config('filesystems.disks.s3.key'),
'secret' => config('filesystems.disks.s3.secret'),
],
]);
$presignedUrl = (string) $s3->createPresignedRequest($command, '+15 minutes')->getUri();
$command = $s3->getCommand('PutObject', [
'Bucket' => config('filesystems.disks.s3.bucket'),
'Key' => $key,
'ContentType' => $validated['mime_type'],
]);
$presignedUrl = (string) $s3->createPresignedRequest($command, '+15 minutes')->getUri();
} else {
$presignedUrl = route('media.local-upload', ['key' => $key]);
}
return response()->json([
'key' => $key,
@ -44,4 +51,19 @@ public function presign(Request $request): JsonResponse
'expires_at' => now()->addMinutes(15)->toISOString(),
]);
}
public function localUpload(Request $request): JsonResponse
{
$key = $request->query('key');
if (! $key || ! str_starts_with($key, 'temp/')) {
return response()->json(['error' => 'Invalid key'], 400);
}
$content = $request->getContent();
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
Storage::disk($disk)->put($key, $content);
return response()->json(['success' => true]);
}
}

View File

@ -3,11 +3,14 @@
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CuttingRequest extends FormRequest
{
use ValidatesMediaUploads;
public function authorize(): bool
{
$permission = $this->isMethod('POST')
@ -53,7 +56,10 @@ public function rules(): array
$rules['results.*.original_outside_sample'] = ['required', 'integer', 'min:0'];
}
return $rules;
return array_merge(
$rules,
$this->photoRules('photos', 10),
);
}
/**
@ -61,18 +67,21 @@ public function rules(): array
*/
public function attributes(): array
{
return [
'description' => 'Keterangan',
'materials' => 'Bahan Baku',
'materials.*.raw_material_price_id' => 'Bahan Baku',
'materials.*.material_usage' => 'Pemakaian',
'results' => 'Hasil Produk',
'results.*.product_variant_id' => 'Varian Produk',
'results.*.cutting_result' => 'Hasil',
'results.*.sample' => 'sample',
'results.*.original_outside_sample' => 'Diluar sample',
'sewing_cost' => 'Jasa Jahit',
'other_cost' => 'Biaya Lainnya',
];
return array_merge(
[
'description' => 'Keterangan',
'materials' => 'Bahan Baku',
'materials.*.raw_material_price_id' => 'Bahan Baku',
'materials.*.material_usage' => 'Pemakaian',
'results' => 'Hasil Produk',
'results.*.product_variant_id' => 'Varian Produk',
'results.*.cutting_result' => 'Hasil',
'results.*.sample' => 'sample',
'results.*.original_outside_sample' => 'Diluar sample',
'sewing_cost' => 'Jasa Jahit',
'other_cost' => 'Biaya Lainnya',
],
$this->photoUploadAttributes('Foto Cutting'),
);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\CuttingStatus;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\HasRejection;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
@ -16,16 +17,17 @@
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Validation\ValidationException;
use Spatie\MediaLibrary\HasMedia;
#[Guarded(['id'])]
#[Appends([
'created_at_formatted',
'status_label',
])]
class Cutting extends Model
class Cutting extends Model implements HasMedia
{
// 1. Use Trait
use HasFactory, HasRejection, InteractsWithActivityLog, SoftDeletes;
use HasFactory, HasModuleMedia, HasRejection, InteractsWithActivityLog, SoftDeletes;
// 2. Casting
protected function casts(): array
@ -81,6 +83,16 @@ public function statusLabel(): Attribute
}
// 5. Other Methods
public static function mediaModuleName(): string
{
return 'cutting';
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('images');
}
public function ensureEditable(): void
{
if (! $this->status->isEditable()) {

View File

@ -35,6 +35,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
{
$query = Cutting::query()
->with([
'media',
'createdBy.profile',
'rejection.rejectedBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
@ -66,7 +67,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
$cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user));
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
$this->appendCostPreview($cutting);
$this->appendPhotos($cutting);
$this->appendImages($cutting);
$cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m));
return $cutting;
@ -77,6 +78,7 @@ public function getInProgressCuttings(User $user): Collection
{
return Cutting::query()
->with([
'media',
'createdBy.profile',
'rejection.rejectedBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
@ -91,7 +93,7 @@ public function getInProgressCuttings(User $user): Collection
$cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user));
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
$this->appendCostPreview($cutting);
$this->appendPhotos($cutting);
$this->appendImages($cutting);
$cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m));
});
}
@ -100,6 +102,7 @@ public function getCompletedCuttings(User $user): Collection
{
return Cutting::query()
->with([
'media',
'createdBy.profile',
'rejection.rejectedBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
@ -114,7 +117,7 @@ public function getCompletedCuttings(User $user): Collection
$cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user));
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
$this->appendCostPreview($cutting);
$this->appendPhotos($cutting);
$this->appendImages($cutting);
$cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m));
});
}
@ -189,6 +192,7 @@ public function productCatalog(?Cutting $cutting = null, ?User $user = null): Co
public function findForEdit(Cutting $cutting): Cutting
{
$cutting->load([
'media',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'materials.rawMaterialPrice.media',
'results.productVariant.product:id,name',
@ -196,7 +200,7 @@ public function findForEdit(Cutting $cutting): Cutting
'rejection.rejectedBy.profile',
]);
$this->appendPhotos($cutting);
$this->appendImages($cutting);
$cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m));
return $cutting;
@ -205,6 +209,7 @@ public function findForEdit(Cutting $cutting): Cutting
public function findForShare(Cutting $cutting): Cutting
{
$cutting->load([
'media',
'createdBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'materials.rawMaterialPrice.media',
@ -213,14 +218,16 @@ public function findForShare(Cutting $cutting): Cutting
]);
$this->appendCostPreview($cutting);
$this->appendPhotos($cutting);
$this->appendImages($cutting);
$cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m));
return $cutting;
}
private function appendPhotos(Cutting $cutting): void
private function appendImages(Cutting $cutting): void
{
$cutting->setAttribute('images', MediaPresenter::collection($cutting, 'images'));
$cutting->materials->each(function (CuttingMaterial $material): void {
$price = $material->rawMaterialPrice;
@ -423,6 +430,8 @@ public function create(array $validated, User $user): Cutting
'created_by_id' => $user->id,
]);
$this->syncImages($cutting, $validated);
foreach ($draftMaterials as $material) {
$material->cutting_id = $cutting->id;
$material->user_id = null;
@ -495,6 +504,8 @@ public function update(Cutting $cutting, array $validated): void
}
$cutting->save();
$this->syncImages($cutting, $validated);
foreach ($materials as $materialData) {
$cutting->materials()->create($materialData);
}
@ -551,6 +562,7 @@ public function delete(Cutting $cutting): void
$cutting->materials()->delete();
$cutting->results()->delete();
$cutting->clearMediaCollection('images');
$cutting->delete();
});
} catch (ValidationException $e) {
@ -1164,4 +1176,18 @@ private function formatStockForUnit(float $stock, RawMaterialUnit $unit): string
return "{$formatted} {$unit->abbreviation()}";
}
private function syncImages(Cutting $cutting, array $validated): void
{
$this->mediaService->syncCollection(
$cutting,
'images',
$validated['images'] ?? null,
$validated['remove_media_ids'] ?? null,
10,
required: false,
errorKey: 'images',
s3Keys: $validated['s3_keys'] ?? null,
);
}
}

View File

@ -90,12 +90,13 @@ public function registerS3Key(
string $collection,
?string $type = null,
): Media {
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
try {
return $model->addMediaFromDisk($s3Key, 's3')
return $model->addMediaFromDisk($s3Key, $disk)
->withCustomProperties($this->customProperties($model, $type ?? $collection))
->toMediaCollection($collection);
} finally {
Storage::disk('s3')->delete($s3Key);
Storage::disk($disk)->delete($s3Key);
}
}

View File

@ -2,7 +2,6 @@
namespace App\Support\Media;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
@ -48,11 +47,20 @@ private static function temporaryUrl(Media $media, ?string $conversion = null):
$disk = $media->disk;
if ($disk === 's3') {
$path = $conversion
? $media->getPath($conversion)
: $media->getPath();
$visibility = config('filesystems.disks.s3.visibility', 'private');
if ($visibility === 'public') {
return $conversion
? $media->getUrl($conversion)
: $media->getUrl();
}
return Storage::disk($disk)->temporaryUrl($path, now()->addMinutes(30));
try {
return $media->getTemporaryUrl(now()->addMinutes(30), $conversion ?? '');
} catch (\Exception $e) {
return $conversion
? $media->getUrl($conversion)
: $media->getUrl();
}
}
return $conversion

View File

@ -56,6 +56,7 @@
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'visibility' => env('AWS_VISIBILITY', 'private'),
'throw' => false,
'report' => false,
],

View File

@ -65,6 +65,7 @@ export type CuttingListItem = {
status: string;
status_label: string;
description: string | null;
images?: MediaItem[];
is_editable: boolean;
available_actions: CuttingStatusAction[];
created_at_formatted: string;
@ -134,6 +135,7 @@ export type CuttingEditItem = {
id: number;
status: string;
description: string | null;
images?: MediaItem[];
sewing_cost?: number;
other_cost?: number;
materials: Array<{

View File

@ -52,6 +52,7 @@
// Media presigned upload
Route::post('/admin/media/presign', [PresignedUploadController::class, 'presign'])->name('media.presign');
Route::put('/admin/media/local-upload', [PresignedUploadController::class, 'localUpload'])->name('media.local-upload');
// Push Notifications
Route::post('/push-subscriptions', [PushSubscriptionController::class, 'store'])->name('push_subscriptions.store');