feat: enhance PresignedUploadController to support local uploads and improve S3 integration; add localUpload method for handling uploads when S3 is not the default disk
This commit is contained in:
parent
4eea267756
commit
3d00511626
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
],
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user