From 4baa4cb52b84bbdd0c304989e20fefe844af7419 Mon Sep 17 00:00:00 2001 From: Hank Date: Tue, 12 May 2026 03:54:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=AF=A6=E4=BD=9C=E8=AA=B2=E7=A8=8B?= =?UTF-8?q?=E5=9C=96=E7=89=87=E4=B8=8A=E5=82=B3=20=E2=80=94=20=E5=B0=81?= =?UTF-8?q?=E9=9D=A2=20+=20=E7=9B=B8=E7=B0=BF=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 後端: - Migration:diving_offers 新增 cover_image 欄位、新增 course_images 表(含索引) - CourseImage Model(CREATED_AT、url accessor) - DivingOffer:cover_image_url accessor、hasMany courseImages、static::deleting() 孤兒清理 - CourseImageController:封面上傳/刪除、相簿上傳(max 3)/刪除,統一 mimes+size 驗證 - DivingOfferController:index/show 回傳加入 cover_image_url 與 images 陣列 - 修正 APP_URL 加入 port(:8080),Storage::url() 才能產生正確圖片連結 前端: - courseImageApi.js:uploadCover/deleteCover/uploadImage/deleteImage - CourseCard:有封面顯示 ,無封面顯示漸層佔位 - CourseDetailView:封面大圖 + 相簿縮圖橫列(點擊開新分頁) - OfferFormView(編輯模式):封面預覽/更換/刪除、相簿縮圖管理(達 3 張隱藏上傳按鈕) 基礎設施: - docker-entrypoint.sh:加入 storage:link --force - docker-compose.yml:移除 storage-data named volume(改用 bind mount,避免 Nginx 讀不到圖片) 測試: - CourseImageTest.php:14 個 Feature Test 全部 PASS(Storage::fake) 涵蓋:上傳成功/格式驗證/大小驗證/所有權、刪除/無封面不報錯、 相簿上限/sort_order 遞增、孤兒清理 OpenSpec: - course-images change 歸檔至 archive/2026-05-12-course-images - 新增 specs/course-image-upload 主規格(含 bind mount 持久化說明) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Controllers/API/CourseImageController.php | 99 ++++++++ .../Controllers/API/DivingOfferController.php | 31 ++- app/Models/CourseImage.php | 33 +++ app/Models/DivingOffer.php | 21 ++ ...add_cover_image_to_diving_offers_table.php | 25 ++ ...5_11_191738_create_course_images_table.php | 32 +++ docker/php/docker-entrypoint.sh | 4 + frontend/src/api/courseImageApi.js | 27 +++ frontend/src/components/CourseCard.vue | 12 +- frontend/src/views/CourseDetailView.vue | 26 +- frontend/src/views/coach/OfferFormView.vue | 97 ++++++++ .../2026-05-12-course-images/.openspec.yaml | 2 + .../2026-05-12-course-images/README.md | 3 + .../2026-05-12-course-images/design.md | 147 ++++++++++++ .../2026-05-12-course-images/proposal.md | 41 ++++ .../specs/course-image-upload/spec.md | 79 ++++++ .../archive/2026-05-12-course-images/tasks.md | 63 +++++ openspec/specs/course-image-upload/spec.md | 84 +++++++ routes/api.php | 6 + tests/Feature/CourseImageTest.php | 227 ++++++++++++++++++ 20 files changed, 1048 insertions(+), 11 deletions(-) create mode 100644 app/Http/Controllers/API/CourseImageController.php create mode 100644 app/Models/CourseImage.php create mode 100644 database/migrations/2026_05_11_191737_add_cover_image_to_diving_offers_table.php create mode 100644 database/migrations/2026_05_11_191738_create_course_images_table.php create mode 100644 frontend/src/api/courseImageApi.js create mode 100644 openspec/changes/archive/2026-05-12-course-images/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-12-course-images/README.md create mode 100644 openspec/changes/archive/2026-05-12-course-images/design.md create mode 100644 openspec/changes/archive/2026-05-12-course-images/proposal.md create mode 100644 openspec/changes/archive/2026-05-12-course-images/specs/course-image-upload/spec.md create mode 100644 openspec/changes/archive/2026-05-12-course-images/tasks.md create mode 100644 openspec/specs/course-image-upload/spec.md create mode 100644 tests/Feature/CourseImageTest.php diff --git a/app/Http/Controllers/API/CourseImageController.php b/app/Http/Controllers/API/CourseImageController.php new file mode 100644 index 0000000..6cfcfdb --- /dev/null +++ b/app/Http/Controllers/API/CourseImageController.php @@ -0,0 +1,99 @@ +validate([ + 'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048', + ]); + } + + private function authorizeOffer(Request $request, DivingOffer $offer): void + { + if ($offer->provider_id !== $request->user()->id) { + abort(403, '無權操作此課程'); + } + } + + public function uploadCover(Request $request, int $offerId) + { + $offer = DivingOffer::findOrFail($offerId); + $this->authorizeOffer($request, $offer); + $this->validateImage($request); + + if ($offer->cover_image) { + Storage::disk('public')->delete($offer->cover_image); + } + + $path = $request->file('image')->store("offers/{$offerId}/cover", 'public'); + $offer->update(['cover_image' => $path]); + + return response()->json([ + 'status' => true, + 'message' => '封面已上傳', + 'cover_image_url' => $offer->cover_image_url, + ]); + } + + public function deleteCover(Request $request, int $offerId) + { + $offer = DivingOffer::findOrFail($offerId); + $this->authorizeOffer($request, $offer); + + if ($offer->cover_image) { + Storage::disk('public')->delete($offer->cover_image); + $offer->update(['cover_image' => null]); + } + + return response()->json(['status' => true, 'message' => '封面已刪除']); + } + + public function uploadImage(Request $request, int $offerId) + { + $offer = DivingOffer::findOrFail($offerId); + $this->authorizeOffer($request, $offer); + $this->validateImage($request); + + if ($offer->courseImages()->count() >= 3) { + return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422); + } + + $path = $request->file('image')->store("offers/{$offerId}/gallery", 'public'); + $sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1; + + $image = CourseImage::create([ + 'diving_offer_id' => $offerId, + 'image_path' => $path, + 'sort_order' => $sortOrder, + ]); + + return response()->json([ + 'status' => true, + 'message' => '圖片已上傳', + 'data' => ['id' => $image->id, 'url' => $image->url, 'sort_order' => $image->sort_order], + ], 201); + } + + public function deleteImage(Request $request, int $imageId) + { + $image = CourseImage::with('divingOffer')->findOrFail($imageId); + + if ($image->divingOffer->provider_id !== $request->user()->id) { + return response()->json(['status' => false, 'message' => '無權刪除此圖片'], 403); + } + + Storage::disk('public')->delete($image->image_path); + $image->delete(); + + return response()->json(['status' => true, 'message' => '圖片已刪除']); + } +} diff --git a/app/Http/Controllers/API/DivingOfferController.php b/app/Http/Controllers/API/DivingOfferController.php index 1c07209..3a8b574 100644 --- a/app/Http/Controllers/API/DivingOfferController.php +++ b/app/Http/Controllers/API/DivingOfferController.php @@ -32,9 +32,11 @@ class DivingOfferController extends Controller $paginated = $query->paginate($perPage); + $items = collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false)); + return response()->json([ 'status' => true, - 'data' => $paginated->items(), + 'data' => $items, 'meta' => [ 'total' => $paginated->total(), 'per_page' => $paginated->perPage(), @@ -46,18 +48,29 @@ class DivingOfferController extends Controller public function show(int $id) { - $offer = DivingOffer::find($id); + $offer = DivingOffer::with('courseImages')->find($id); if (!$offer) { - return response()->json([ - 'status' => false, - 'message' => '課程不存在', - ], 404); + return response()->json(['status' => false, 'message' => '課程不存在'], 404); } - return response()->json([ - 'status' => true, - 'data' => $offer, + return response()->json(['status' => true, 'data' => $this->formatOffer($offer, true)]); + } + + private function formatOffer(DivingOffer $offer, bool $withImages): array + { + $data = array_merge($offer->toArray(), [ + 'cover_image_url' => $offer->cover_image_url, ]); + + if ($withImages) { + $data['images'] = $offer->courseImages->map(fn($img) => [ + 'id' => $img->id, + 'url' => $img->url, + 'sort_order' => $img->sort_order, + ])->values(); + } + + return $data; } } diff --git a/app/Models/CourseImage.php b/app/Models/CourseImage.php new file mode 100644 index 0000000..583bd79 --- /dev/null +++ b/app/Models/CourseImage.php @@ -0,0 +1,33 @@ + 'integer', + 'created_at' => 'datetime', + ]; + + public function divingOffer() + { + return $this->belongsTo(DivingOffer::class); + } + + public function getUrlAttribute(): string + { + return Storage::disk('public')->url($this->image_path); + } +} diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php index edb0489..f396a04 100644 --- a/app/Models/DivingOffer.php +++ b/app/Models/DivingOffer.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Storage; class DivingOffer extends Model { @@ -22,6 +23,7 @@ class DivingOffer extends Model 'description', 'tag', 'region', + 'cover_image', ]; protected $casts = [ @@ -31,11 +33,30 @@ class DivingOffer extends Model 'reviews'=> 'integer', ]; + protected static function booted(): void + { + static::deleting(function ($offer) { + Storage::disk('public')->deleteDirectory("offers/{$offer->id}"); + }); + } + + public function getCoverImageUrlAttribute(): ?string + { + return $this->cover_image + ? Storage::disk('public')->url($this->cover_image) + : null; + } + public function schedules() { return $this->hasMany(CourseSchedule::class, 'diving_offer_id'); } + public function courseImages() + { + return $this->hasMany(CourseImage::class)->orderBy('sort_order'); + } + public function reviews() { return $this->hasMany(Review::class); diff --git a/database/migrations/2026_05_11_191737_add_cover_image_to_diving_offers_table.php b/database/migrations/2026_05_11_191737_add_cover_image_to_diving_offers_table.php new file mode 100644 index 0000000..5f25c0a --- /dev/null +++ b/database/migrations/2026_05_11_191737_add_cover_image_to_diving_offers_table.php @@ -0,0 +1,25 @@ +string('cover_image', 500)->nullable()->after('description'); + }); + } + + public function down(): void + { + Schema::table('diving_offers', function (Blueprint $table) { + $table->dropColumn('cover_image'); + }); + } +}; diff --git a/database/migrations/2026_05_11_191738_create_course_images_table.php b/database/migrations/2026_05_11_191738_create_course_images_table.php new file mode 100644 index 0000000..b165c5e --- /dev/null +++ b/database/migrations/2026_05_11_191738_create_course_images_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete(); + $table->string('image_path', 500); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['diving_offer_id', 'sort_order'], 'idx_course_images_offer_sort'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('course_images'); + } +}; diff --git a/docker/php/docker-entrypoint.sh b/docker/php/docker-entrypoint.sh index e2ffe9e..f29ae75 100644 --- a/docker/php/docker-entrypoint.sh +++ b/docker/php/docker-entrypoint.sh @@ -110,6 +110,10 @@ fi echo "✅ CFDivePlatform 初始化完成!" +# 建立 storage symlink +echo "🔗 建立 storage symlink..." +php artisan storage:link --force || true + # 啟動 cron daemon(Laravel Scheduler) echo "⏰ 啟動 Laravel Scheduler cron..." service cron start || cron || true diff --git a/frontend/src/api/courseImageApi.js b/frontend/src/api/courseImageApi.js new file mode 100644 index 0000000..5b25ab9 --- /dev/null +++ b/frontend/src/api/courseImageApi.js @@ -0,0 +1,27 @@ +import coachApi from './coachAxios' + +function toFormData(file) { + const fd = new FormData() + fd.append('image', file) + return fd +} + +export function uploadCover(offerId, file) { + return coachApi.post(`/provider/offers/${offerId}/cover`, toFormData(file), { + headers: { 'Content-Type': 'multipart/form-data' }, + }) +} + +export function deleteCover(offerId) { + return coachApi.delete(`/provider/offers/${offerId}/cover`) +} + +export function uploadImage(offerId, file) { + return coachApi.post(`/provider/offers/${offerId}/images`, toFormData(file), { + headers: { 'Content-Type': 'multipart/form-data' }, + }) +} + +export function deleteImage(imageId) { + return coachApi.delete(`/provider/images/${imageId}`) +} diff --git a/frontend/src/components/CourseCard.vue b/frontend/src/components/CourseCard.vue index 1635158..917f4ec 100644 --- a/frontend/src/components/CourseCard.vue +++ b/frontend/src/components/CourseCard.vue @@ -9,7 +9,17 @@ defineProps({ :to="`/courses/${offer.id}`" class="bg-white rounded-2xl shadow hover:shadow-lg transition overflow-hidden flex flex-col" > -
🤿
+
+ +
+ 🤿 +
+
diff --git a/frontend/src/views/CourseDetailView.vue b/frontend/src/views/CourseDetailView.vue index 0c0e3f0..bb8fba8 100644 --- a/frontend/src/views/CourseDetailView.vue +++ b/frontend/src/views/CourseDetailView.vue @@ -125,7 +125,31 @@ async function submitBooking() { ← 返回課程列表 -
🤿
+ +
+ +
+ 🤿 +
+
+ + +
+ + + +
{ if (!isEdit.value) return loading.value = true @@ -41,6 +48,8 @@ onMounted(async () => { badges: Array.isArray(o.badges) ? o.badges.join(', ') : (o.badges || ''), description: o.description || '', } + coverUrl.value = o.cover_image_url || null + galleryImgs.value = o.images || [] } catch (e) { error.value = e.response?.data?.message || '無法載入課程資料' } finally { @@ -48,6 +57,49 @@ onMounted(async () => { } }) +async function onCoverChange(e) { + const file = e.target.files[0] + if (!file) return + imgUploading.value = true + imgError.value = '' + try { + const res = await uploadCover(route.params.id, file) + coverUrl.value = res.data.cover_image_url + } catch (e) { + imgError.value = e.response?.data?.message || '封面上傳失敗' + } finally { + imgUploading.value = false + e.target.value = '' + } +} + +async function onDeleteCover() { + if (!confirm('確定刪除封面?')) return + await deleteCover(route.params.id) + coverUrl.value = null +} + +async function onGalleryChange(e) { + const file = e.target.files[0] + if (!file) return + imgUploading.value = true + imgError.value = '' + try { + const res = await uploadImage(route.params.id, file) + galleryImgs.value.push(res.data.data) + } catch (e) { + imgError.value = e.response?.data?.message || '圖片上傳失敗' + } finally { + imgUploading.value = false + e.target.value = '' + } +} + +async function onDeleteImage(img) { + await deleteImage(img.id) + galleryImgs.value = galleryImgs.value.filter(i => i.id !== img.id) +} + async function submit() { errors.value = {} error.value = '' @@ -161,6 +213,51 @@ async function submit() { class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none" />
+ +
+

課程圖片

+

{{ imgError }}

+ + +
+ +
+
+ +
🤿
+
+
+ + +
+
+
+ + +
+ +
+
+ + +
+ +
+

上傳中...

+
+
+
diff --git a/openspec/changes/archive/2026-05-12-course-images/.openspec.yaml b/openspec/changes/archive/2026-05-12-course-images/.openspec.yaml new file mode 100644 index 0000000..81cd71f --- /dev/null +++ b/openspec/changes/archive/2026-05-12-course-images/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-11 diff --git a/openspec/changes/archive/2026-05-12-course-images/README.md b/openspec/changes/archive/2026-05-12-course-images/README.md new file mode 100644 index 0000000..92dd707 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-course-images/README.md @@ -0,0 +1,3 @@ +# course-images + +課程圖片上傳:封面圖 + 相簿最多 3 張,本地 public disk,Docker volume 持久化 diff --git a/openspec/changes/archive/2026-05-12-course-images/design.md b/openspec/changes/archive/2026-05-12-course-images/design.md new file mode 100644 index 0000000..d38e4d4 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-course-images/design.md @@ -0,0 +1,147 @@ +## Context + +CFDivePlatform 課程目前無圖片欄位。Laravel 的 `public` disk(`storage/app/public`)透過 `storage:link` symlink 至 `public/storage`,讓外部可直接存取。Docker 部署需要 named volume 掛載 storage 目錄,確保圖片跨 build 持久化。 + +## Goals / Non-Goals + +**Goals:** +- Provider 可上傳課程封面(1 張)與相簿(最多 3 張) +- 圖片存於本地 public disk,URL 可直接用於 `` +- Docker volume 持久化,重建容器不丟圖 +- `FILESYSTEM_DISK=public` 從 env 控制,未來切換 S3 只改設定 + +**Non-Goals:** +- 圖片後端處理(resize、WebP 轉換) +- S3 / CDN 整合(留給未來) +- 管理員上傳課程圖片(僅 Provider 可上傳自己的課程) +- 圖片排序拖拉(sort_order 按上傳順序自動遞增) + +## Decisions + +### 決策一:資料表設計 + +**`diving_offers` 新增欄位:** +``` +cover_image varchar(500) nullable ← 儲存相對路徑,如 offers/7/cover/abc.jpg +``` + +**新增 `course_images` 資料表:** +``` +id bigint PK +diving_offer_id bigint FK → diving_offers (cascade) +image_path varchar(500) ← 相對路徑 +sort_order unsignedSmallInt DEFAULT 0 +created_at timestamp + +索引:(diving_offer_id, sort_order) +``` + +### 決策二:URL 產生方式 + +`cover_image_url` accessor 與相簿 `url` 統一透過 `Storage::url($path)` 產生: + +```php +// DivingOffer Model accessor +public function getCoverImageUrlAttribute(): ?string +{ + return $this->cover_image + ? Storage::disk('public')->url($this->cover_image) + : null; +} +``` + +**理由**:`Storage::url()` 自動對應 `APP_URL`,未來切換 S3 disk 後 URL 自動改為 S3 endpoint,零修改。 + +### 決策三:檔案儲存路徑 + +``` +storage/app/public/ + offers/ + {offer_id}/ + cover/ + {uuid}.jpg ← 封面(只會有一個) + gallery/ + {uuid}.jpg ← 相簿圖片(最多 3 個) +``` + +UUID 檔名防止猜測,覆蓋封面時先刪舊 UUID 再存新檔。 + +### 決策四:封面覆蓋策略 + +```php +if ($offer->cover_image) { + Storage::disk('public')->delete($offer->cover_image); +} +$path = $request->file('image')->store("offers/{$offer->id}/cover", 'public'); +$offer->update(['cover_image' => $path]); +``` + +不保留歷史封面,節省磁碟空間。 + +### 決策五:Docker 持久化(Bind Mount) + +`app` 與 `nginx` 容器都已掛載 `./:/var/www` bind mount,圖片存至 `./storage/app/public/`,兩個容器透過同一份 host 目錄共享。 + +**不使用 named volume** 的原因:`nginx` 容器需要能直接讀取圖片(Laravel 只寫檔,Nginx 才是實際提供靜態檔的服務)。若只在 `app` 掛 named volume,Nginx 透過 bind mount 找不到該 volume 的檔案,導致圖片 404。Bind mount 對兩個容器一致,是最簡單的解法。 + +`docker-entrypoint.sh` 加入 `php artisan storage:link --force`,每次啟動確保 symlink 正確指向 `./storage/app/public/`。 + +### 決策六:課程刪除時的孤兒檔案清理 + +**選擇**:在 `DivingOffer` Model 加 `static::deleting()` observer,刪除整個課程目錄: + +```php +static::deleting(function ($offer) { + Storage::disk('public')->deleteDirectory("offers/{$offer->id}"); +}); +``` + +**理由**:`course_images` cascade 只清 DB 紀錄,實體檔案殘留。若課程反覆建刪,磁碟累積孤兒目錄。一行程式碼解決,不算 over-engineering。`deleteDirectory` 目錄不存在時不報錯,安全。 + +**放棄**:排程清理孤兒檔 → 有時間差,且需額外邏輯比對 DB 與 storage。 + +--- + +### 決策七:sort_order 不連續為預期行為 + +刪除中間相簿圖片後再新增,`sort_order = MAX(sort_order) + 1`,序號會不連續(如:1, 3 而非 1, 2)。 + +**決定**:MVP 接受不連續。前端依 `sort_order ASC` 排序顯示即可,不需重新排號。 + +**理由**:重新排號需要 UPDATE 多筆紀錄,複雜度不值得(相簿最多 3 張,視覺影響極小)。 + +--- + +### 決策八:course_images 的 created_at 自動填入 + +Migration 使用 `$table->timestamp('created_at')->useCurrent()`,讓 DB 自動填入,不依賴 PHP 層。 + +Model 設定: +```php +public $timestamps = false; +const CREATED_AT = 'created_at'; // 告知 Eloquent 此欄位存在(用於排序) +``` + +**理由**:`useCurrent()` 與 `review_votes` 的做法一致,無需手動傳入 `created_at`。 + +--- + +### 決策九:`FILESYSTEM_DISK` 設定 + +`.env` 的 `FILESYSTEM_DISK=local` 改為 `public`,確保 `Storage::disk()` 預設指向公開路徑。`CourseImageController` 明確指定 `disk('public')`,不依賴預設值,避免混淆。 + +## API 路由總覽 + +``` +Provider (auth:sanctum) + POST /api/provider/offers/{id}/cover ← 上傳/覆蓋封面 + DELETE /api/provider/offers/{id}/cover ← 刪除封面 + POST /api/provider/offers/{id}/images ← 上傳相簿圖片(最多 3 張) + DELETE /api/provider/images/{imageId} ← 刪除特定相簿圖片 +``` + +## Risks / Trade-offs + +- **本地 disk 無法多機部署**:目前單機 Docker 可接受;未來水平擴展必須改 S3,設計已預留切換彈性(`Storage::disk('public')`) +- **檔案孤兒**:若刪除課程(`DivingOffer`)時,`course_images` cascade 刪除 DB 紀錄,但實體檔案不會自動清除。MVP 可接受(磁碟空間有限時手動清理) +- **storage:link 在 Docker 重啟時**:每次 entrypoint 執行 `storage:link --force` 覆蓋重建,確保 symlink 正確 diff --git a/openspec/changes/archive/2026-05-12-course-images/proposal.md b/openspec/changes/archive/2026-05-12-course-images/proposal.md new file mode 100644 index 0000000..ea2a634 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-course-images/proposal.md @@ -0,0 +1,41 @@ +## Why + +目前課程頁面以 🤿 emoji 作為佔位,缺乏視覺吸引力。課程圖片是潛水平台的核心體驗,直接影響會員瀏覽意願與教練品牌形象。 + +## What Changes + +- 新增 `diving_offers.cover_image` 欄位:課程封面,顯示於課程卡與詳情頁頂部 +- 新增 `course_images` 資料表:相簿最多 3 張,顯示於課程詳情頁 +- 新增 Provider API:上傳/刪除封面、上傳/刪除相簿圖片 +- 更新前端:CourseCard 顯示封面、CourseDetailView 顯示封面 + 相簿、OfferFormView 加入圖片管理 UI +- Docker volume 掛載 `storage/app/public`,圖片跨 build 持久化 +- `php artisan storage:link` 整合至 entrypoint,確保 symlink 正確 + +## Capabilities + +### New Capabilities + +- `course-image-upload`:Provider 上傳課程封面與相簿圖片(本地 public disk、max 2MB、支援 jpg/jpeg/png/webp)、相簿上限 3 張、刪除圖片同步移除實體檔案 + +### Modified Capabilities + +- `coach-offers-api`:新增圖片上傳/刪除端點,`DivingOffer` 回應加入 `cover_image_url` 與 `images` + +## Impact + +**後端** +- Migration:`diving_offers` 加 `cover_image`(nullable string) +- Migration:新增 `course_images`(id、diving_offer_id、image_path、sort_order) +- Model:`CourseImage`;`DivingOffer` 加 `hasMany CourseImage`、`cover_image_url` accessor +- Controller:`CourseImageController`(上傳封面、刪除封面、上傳相簿、刪除相簿) +- Route:`/provider/offers/{id}/cover`(POST/DELETE)、`/provider/offers/{id}/images`(POST)、`/provider/images/{id}`(DELETE) + +**前端** +- `frontend/src/api/courseImageApi.js` +- `CourseCard.vue`:有封面顯示圖片,無封面顯示漸層佔位 +- `CourseDetailView.vue`:頂部大圖(封面)+ 相簿縮圖列 +- `OfferFormView.vue`(或新建 `OfferImageManager.vue`):封面上傳預覽、相簿管理 + +**Docker** +- `docker-compose.yml` 新增 named volume `storage-data` 掛載 `/var/www/storage/app/public` +- `docker-entrypoint.sh` 加入 `php artisan storage:link --force` diff --git a/openspec/changes/archive/2026-05-12-course-images/specs/course-image-upload/spec.md b/openspec/changes/archive/2026-05-12-course-images/specs/course-image-upload/spec.md new file mode 100644 index 0000000..5df3931 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-course-images/specs/course-image-upload/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: Provider 上傳課程封面 +Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆蓋舊封面並刪除舊實體檔案。 + +#### Scenario: 成功上傳封面 +- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/cover`,包含 `image` 檔案(jpeg/png/webp,≤2MB) +- **THEN** 系統儲存檔案至 `public` disk 的 `offers/{offer_id}/cover/` 目錄,更新 `diving_offers.cover_image`,回傳 `cover_image_url` + +#### Scenario: 覆蓋封面時刪除舊檔 +- **WHEN** Provider 上傳新封面,且原本已有封面 +- **THEN** 系統先刪除舊實體檔案,再儲存新檔案 + +#### Scenario: 檔案格式驗證 +- **WHEN** 上傳的檔案不是 jpeg/jpg/png/webp +- **THEN** 系統回傳 422,message:「只支援 jpeg、png、webp 格式」 + +#### Scenario: 檔案大小驗證 +- **WHEN** 上傳檔案超過 2MB(2048KB) +- **THEN** 系統回傳 422,message:「圖片大小不可超過 2MB」 + +#### Scenario: 不可上傳他人課程的封面 +- **WHEN** Provider 嘗試上傳不屬於自己課程的封面 +- **THEN** 系統回傳 403 + +### Requirement: Provider 刪除課程封面 +Provider SHALL 能刪除自己課程的封面,同步移除實體檔案。 + +#### Scenario: 成功刪除封面 +- **WHEN** Provider 送出 `DELETE /api/provider/offers/{id}/cover` +- **THEN** 系統刪除實體檔案,將 `diving_offers.cover_image` 設為 null,回傳 200 + +#### Scenario: 無封面時刪除不報錯 +- **WHEN** Provider 刪除封面,但 `cover_image` 本來就是 null +- **THEN** 系統直接回傳 200,不報錯 + +### Requirement: Provider 上傳課程相簿圖片 +Provider SHALL 能為課程上傳最多 3 張相簿圖片。 + +#### Scenario: 成功上傳相簿圖片 +- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/images`,包含 `image` 檔案(格式與大小同封面限制),且目前相簿圖片數 < 3 +- **THEN** 系統儲存檔案至 `offers/{offer_id}/gallery/` 目錄,建立 `course_images` 紀錄,`sort_order` 為目前最大值 + 1,回傳新圖片資訊 + +#### Scenario: 超過 3 張上限 +- **WHEN** 課程已有 3 張相簿圖片,Provider 再次上傳 +- **THEN** 系統回傳 422,message:「相簿最多 3 張圖片」 + +#### Scenario: 不可上傳他人課程的相簿 +- **WHEN** Provider 嘗試上傳不屬於自己課程的相簿 +- **THEN** 系統回傳 403 + +### Requirement: Provider 刪除相簿圖片 +Provider SHALL 能刪除自己課程的特定相簿圖片,同步移除實體檔案。 + +#### Scenario: 成功刪除相簿圖片 +- **WHEN** Provider 送出 `DELETE /api/provider/images/{image_id}` +- **THEN** 系統刪除實體檔案,刪除 `course_images` 紀錄,回傳 200 + +#### Scenario: 不可刪除他人相簿圖片 +- **WHEN** Provider 嘗試刪除不屬於自己課程的相簿圖片 +- **THEN** 系統回傳 403 + +### Requirement: 圖片 URL 隨課程資料一同回傳 +公開課程 API SHALL 在回傳課程資料時包含 `cover_image_url`(完整可存取 URL)與 `images`(相簿陣列)。 + +#### Scenario: 有封面時回傳完整 URL +- **WHEN** 任何人取得課程資料(`GET /api/diving-offers/{id}` 或列表) +- **THEN** `cover_image_url` 回傳可直接用於 `` 的完整 URL;無封面時回傳 null + +#### Scenario: 相簿陣列回傳 +- **WHEN** 取得課程詳情 `GET /api/diving-offers/{id}` +- **THEN** `images` 回傳相簿圖片陣列,每筆含 `id`、`url`、`sort_order`;無相簿時回傳空陣列 + +### Requirement: 圖片儲存持久化 +圖片 SHALL 儲存於 Docker named volume,跨容器重建後仍可存取。 + +#### Scenario: 容器重建後圖片保留 +- **WHEN** 執行 `docker compose build` 並重新啟動容器 +- **THEN** 先前上傳的圖片仍可正常存取(URL 不變) diff --git a/openspec/changes/archive/2026-05-12-course-images/tasks.md b/openspec/changes/archive/2026-05-12-course-images/tasks.md new file mode 100644 index 0000000..656f1e4 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-course-images/tasks.md @@ -0,0 +1,63 @@ +## 1. 資料庫層 + +- [x] 1.1 [後端] 建立 Migration `add_cover_image_to_diving_offers_table`:新增 `cover_image varchar(500) nullable` +- [x] 1.2 [後端] 建立 Migration `create_course_images_table`:欄位含 `diving_offer_id`(FK cascade)、`image_path`(varchar 500)、`sort_order`(unsignedSmallInt DEFAULT 0)、`$table->timestamp('created_at')->useCurrent()`(DB 自動填入,無 updated_at);加索引 `(diving_offer_id, sort_order)` +- [x] 1.3 [後端] 執行 Migration,確認欄位與索引正確 + +## 2. Model 層 + +- [x] 2.1 [後端] 建立 `app/Models/CourseImage.php`:fillable(diving_offer_id、image_path、sort_order)、`public $timestamps = false`、`const CREATED_AT = 'created_at'`(讓 Eloquent 知道此欄存在)、belongsTo DivingOffer、`url` accessor(`Storage::disk('public')->url($this->image_path)`) +- [x] 2.2 [後端] 更新 `app/Models/DivingOffer.php`:新增 `hasMany CourseImage` 關聯、`cover_image_url` accessor(`Storage::disk('public')->url($this->cover_image)` 或 null)、新增 `static::deleting()` observer:`Storage::disk('public')->deleteDirectory("offers/{$offer->id}")`(刪除整個課程目錄,防孤兒檔) +- [x] 2.3 [後端] 更新 `DivingOfferController` 的 `show` 與 `index` 回傳:加入 `cover_image_url` 與 `images`(含 id、url、sort_order,依 sort_order ASC 排序) + +## 3. 圖片上傳 API + +- [x] 3.1 [後端] 建立 `app/Http/Controllers/API/CourseImageController.php`: + - 所有上傳方法使用統一 validate:`'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048'` + - `uploadCover`:所有權驗證 → validate → 刪除舊封面實體檔(若有)→ `store("offers/{id}/cover", 'public')` → 更新 `cover_image` → 回傳 `cover_image_url` + - `deleteCover`:所有權驗證 → 刪除實體檔 → `cover_image = null` → 回傳 200(無封面時不報錯) + - `uploadImage`:所有權驗證 → validate → 相簿數量檢查(COUNT < 3,否則 422)→ store 至 `offers/{id}/gallery` → 建立 CourseImage(`sort_order = (MAX(sort_order) ?? 0) + 1`,不連續為預期行為)→ 回傳圖片資訊 + - `deleteImage`:CourseImage 所有權驗證(`$image->divingOffer->provider_id !== auth()->id()` → 403)→ 刪除實體檔 → 刪除 DB 紀錄 → 回傳 200 +- [x] 3.2 [後端] 在 `routes/api.php` Provider 群組新增四個路由(POST/DELETE cover、POST images、DELETE images/{id}) + +## 4. Docker 設定 + +- [x] 4.1 [基礎設施] 在 `docker-compose.yml` 的 `app` service 新增 volume `storage-data:/var/www/storage/app/public`,並在底部 `volumes:` 區塊宣告 `storage-data:` +- [x] 4.2 [基礎設施] 在 `docker/php/docker-entrypoint.sh` 的初始化段落加入 `php artisan storage:link --force || true` +- [x] 4.3 [基礎設施] 重新 build 並啟動容器,確認 `/var/www/public/storage` symlink 存在且可存取 + +## 5. 前端 API 封裝 + +- [x] 5.1 [前端] 建立 `frontend/src/api/courseImageApi.js`:`uploadCover(offerId, file)`、`deleteCover(offerId)`、`uploadImage(offerId, file)`、`deleteImage(imageId)`(皆使用 coachAxios,Content-Type: multipart/form-data) + +## 6. 前端:課程卡封面顯示 + +- [x] 6.1 [前端] 更新 `frontend/src/components/CourseCard.vue`:有 `cover_image_url` 時顯示 ``,無時顯示漸層佔位(保留 🤿 emoji 或 ocean 漸層背景) + +## 7. 前端:課程詳情頁圖片展示 + +- [x] 7.1 [前端] 更新 `frontend/src/views/CourseDetailView.vue`:頂部大圖改為封面(有封面顯示圖片,無封面顯示漸層佔位) +- [x] 7.2 [前端] 相簿縮圖列:`images.length > 0` 時在封面下方顯示最多 3 張縮圖橫列,點擊放大(用 `` 原始連結即可,不需 lightbox) + +## 8. 前端:教練圖片管理 UI + +- [x] 8.1 [前端] 更新 `frontend/src/views/coach/OfferFormView.vue`(或新建 `OfferImageManager.vue`):編輯模式下在表單下方加入圖片管理區塊 + - 封面區:顯示目前封面縮圖 + 「更換封面」按鈕(file input)+ 「刪除封面」按鈕 + - 相簿區:顯示目前 0–3 張縮圖 + 「新增圖片」按鈕(達 3 張時隱藏)+ 每張縮圖右上角「✕」刪除 + +## 9. 整合驗證(手動) + +- [x] 9.1 [整合測試] 上傳封面:上傳後確認 `cover_image_url` 在 API 回傳,且 URL 可直接 GET 存取(HTTP 200) +- [x] 9.2 [整合測試] 覆蓋封面:二次上傳後確認舊實體檔案已從 storage 刪除 +- [x] 9.3 [整合測試] 相簿上限:上傳第 4 張應回傳 422 +- [x] 9.4 [整合測試] Docker 持久化:`docker compose build app && docker compose up -d app` 後,先前上傳的圖片 URL 仍可存取 +- [x] 9.5 [整合測試] 所有權驗證:Provider A 不可上傳到 Provider B 的課程(應回傳 403) + +## 10. Feature Test(自動化) + +- [x] 10.1 [測試] 建立 `tests/Feature/CourseImageTest.php`:使用 `Storage::fake('public')` + `UploadedFile::fake()->image()`,不寫真實檔案 +- [x] 10.2 [測試] 測試 `uploadCover`:成功上傳(201)、格式錯誤(422)、超過 2MB(422)、他人課程(403) +- [x] 10.3 [測試] 測試 `deleteCover`:成功刪除(200)、無封面時不報錯(200)、他人課程(403)、確認 Storage::fake 內舊檔已刪 +- [x] 10.4 [測試] 測試 `uploadImage`:成功上傳(201)、第 4 張回傳 422、他人課程(403)、確認 sort_order = MAX + 1 +- [x] 10.5 [測試] 測試 `deleteImage`:成功刪除(200)、他人圖片(403)、確認實體檔已從 Storage::fake 刪除 +- [x] 10.6 [測試] 測試課程刪除孤兒清理:刪除 DivingOffer 後確認 `offers/{id}/` 目錄從 Storage::fake 消失 diff --git a/openspec/specs/course-image-upload/spec.md b/openspec/specs/course-image-upload/spec.md new file mode 100644 index 0000000..a577627 --- /dev/null +++ b/openspec/specs/course-image-upload/spec.md @@ -0,0 +1,84 @@ +### Requirement: Provider 上傳課程封面 +Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆蓋舊封面並刪除舊實體檔案。 + +#### Scenario: 成功上傳封面 +- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/cover`,包含 `image` 檔案(jpeg/png/webp,≤2MB) +- **THEN** 系統儲存檔案至 `public` disk 的 `offers/{offer_id}/cover/` 目錄,更新 `diving_offers.cover_image`,回傳 `cover_image_url` + +#### Scenario: 覆蓋封面時刪除舊檔 +- **WHEN** Provider 上傳新封面,且原本已有封面 +- **THEN** 系統先刪除舊實體檔案,再儲存新檔案 + +#### Scenario: 檔案格式驗證 +- **WHEN** 上傳的檔案不是 jpeg/jpg/png/webp +- **THEN** 系統回傳 422 + +#### Scenario: 檔案大小驗證 +- **WHEN** 上傳檔案超過 2MB(2048KB) +- **THEN** 系統回傳 422 + +#### Scenario: 不可上傳他人課程的封面 +- **WHEN** Provider 嘗試上傳不屬於自己課程的封面 +- **THEN** 系統回傳 403 + +### Requirement: Provider 刪除課程封面 +Provider SHALL 能刪除自己課程的封面,同步移除實體檔案。 + +#### Scenario: 成功刪除封面 +- **WHEN** Provider 送出 `DELETE /api/provider/offers/{id}/cover` +- **THEN** 系統刪除實體檔案,將 `diving_offers.cover_image` 設為 null,回傳 200 + +#### Scenario: 無封面時刪除不報錯 +- **WHEN** Provider 刪除封面,但 `cover_image` 本來就是 null +- **THEN** 系統直接回傳 200,不報錯 + +### Requirement: Provider 上傳課程相簿圖片 +Provider SHALL 能為課程上傳最多 3 張相簿圖片,sort_order 不連續為預期行為(刪除後重新上傳序號接續最大值)。 + +#### Scenario: 成功上傳相簿圖片 +- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/images`,包含 `image` 檔案(格式與大小同封面限制),且目前相簿圖片數 < 3 +- **THEN** 系統儲存檔案至 `offers/{offer_id}/gallery/` 目錄,建立 `course_images` 紀錄,`sort_order = MAX(sort_order) + 1`,回傳新圖片資訊 + +#### Scenario: 超過 3 張上限 +- **WHEN** 課程已有 3 張相簿圖片,Provider 再次上傳 +- **THEN** 系統回傳 422,message:「相簿最多 3 張圖片」 + +#### Scenario: 不可上傳他人課程的相簿 +- **WHEN** Provider 嘗試上傳不屬於自己課程的相簿 +- **THEN** 系統回傳 403 + +### Requirement: Provider 刪除相簿圖片 +Provider SHALL 能刪除自己課程的特定相簿圖片,同步移除實體檔案。 + +#### Scenario: 成功刪除相簿圖片 +- **WHEN** Provider 送出 `DELETE /api/provider/images/{image_id}` +- **THEN** 系統刪除實體檔案,刪除 `course_images` 紀錄,回傳 200 + +#### Scenario: 不可刪除他人相簿圖片 +- **WHEN** Provider 嘗試刪除不屬於自己課程的相簿圖片 +- **THEN** 系統回傳 403 + +### Requirement: 課程刪除時清理圖片目錄 +DivingOffer 刪除時,系統 SHALL 自動清除 `offers/{offer_id}/` 整個目錄,防止孤兒檔案累積。 + +#### Scenario: 刪除課程時清除圖片 +- **WHEN** DivingOffer 被刪除(`static::deleting()` observer 觸發) +- **THEN** `Storage::disk('public')->deleteDirectory("offers/{$offer->id}")` 刪除封面與相簿所有實體檔案 + +### Requirement: 圖片 URL 隨課程資料一同回傳 +公開課程 API SHALL 在回傳課程資料時包含 `cover_image_url`(含 APP_URL 與 port 的完整 URL)與 `images`(相簿陣列)。 + +#### Scenario: 有封面時回傳完整 URL +- **WHEN** 任何人取得課程資料(`GET /api/diving-offers/{id}` 或列表) +- **THEN** `cover_image_url` 回傳可直接用於 `` 的完整 URL(如 `http://host:port/storage/offers/...`);無封面時回傳 null + +#### Scenario: 相簿陣列回傳 +- **WHEN** 取得課程詳情 `GET /api/diving-offers/{id}` +- **THEN** `images` 回傳相簿圖片陣列,每筆含 `id`、`url`、`sort_order`,依 `sort_order ASC` 排序;無相簿時回傳空陣列 + +### Requirement: 圖片儲存持久化(Bind Mount) +圖片 SHALL 儲存於 `./storage/app/public/`(host bind mount),`app` 與 `nginx` 容器透過 `./:/var/www` 共享同一目錄,跨容器重建後仍可存取。`APP_URL` 必須包含正確 port(如 `http://localhost:8080`)才能產生正確的圖片 URL。 + +#### Scenario: 容器重建後圖片保留 +- **WHEN** 執行 `docker compose up --build` 並重新啟動容器 +- **THEN** 先前上傳的圖片仍可正常存取(URL 不變),因圖片在 host 目錄不受容器重建影響 diff --git a/routes/api.php b/routes/api.php index e57f42a..0c0d2be 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,7 @@ use App\Http\Controllers\API\MemberBookingController; use App\Http\Controllers\API\ReviewController; use App\Http\Controllers\API\AdminReviewController; use App\Http\Controllers\API\AdminBookingController; +use App\Http\Controllers\API\CourseImageController; use App\Http\Controllers\API\AdminStatsController; use App\Http\Controllers\API\AdminUserController; use App\Http\Controllers\API\AdminOfferController; @@ -85,6 +86,11 @@ Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () { Route::get('/offers/{id}', [ProviderOfferController::class, 'show']); Route::put('/offers/{id}', [ProviderOfferController::class, 'update']); Route::delete('/offers/{id}', [ProviderOfferController::class, 'destroy']); + // 課程圖片 + Route::post('/offers/{id}/cover', [CourseImageController::class, 'uploadCover']); + Route::delete('/offers/{id}/cover', [CourseImageController::class, 'deleteCover']); + Route::post('/offers/{id}/images', [CourseImageController::class, 'uploadImage']); + Route::delete('/images/{id}', [CourseImageController::class, 'deleteImage']); // 時段管理 Route::get('/schedules', [ScheduleController::class, 'index']); Route::post('/schedules', [ScheduleController::class, 'store']); diff --git a/tests/Feature/CourseImageTest.php b/tests/Feature/CourseImageTest.php new file mode 100644 index 0000000..291ed4e --- /dev/null +++ b/tests/Feature/CourseImageTest.php @@ -0,0 +1,227 @@ +create(['role' => 'provider']); + } + + private function makeOffer(User $provider): DivingOffer + { + return DivingOffer::create([ + 'provider_id' => $provider->id, + 'title' => 'Test Course', + 'location' => 'Test', + 'spot' => 'Test Spot', + 'price' => 1000, + 'region' => '南部', + 'rating' => 0, + 'reviews' => 0, + ]); + } + + private function fakeImage(string $name = 'test.png'): UploadedFile + { + return UploadedFile::fake()->image($name, 100, 100)->size(500); + } + + public function test_upload_cover_success(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + $offer = $this->makeOffer($provider); + + $this->actingAs($provider) + ->postJson("/api/provider/offers/{$offer->id}/cover", ['image' => $this->fakeImage()]) + ->assertOk()->assertJsonPath('status', true); + + $offer->refresh(); + $this->assertNotNull($offer->cover_image); + Storage::disk('public')->assertExists($offer->cover_image); + } + + public function test_upload_cover_wrong_mime(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + $offer = $this->makeOffer($provider); + + $this->actingAs($provider) + ->postJson("/api/provider/offers/{$offer->id}/cover", [ + 'image' => UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf'), + ])->assertStatus(422); + } + + public function test_upload_cover_too_large(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + $offer = $this->makeOffer($provider); + + $this->actingAs($provider) + ->postJson("/api/provider/offers/{$offer->id}/cover", [ + 'image' => UploadedFile::fake()->image('big.png')->size(3000), + ])->assertStatus(422); + } + + public function test_upload_cover_forbidden_for_other_provider(): void + { + Storage::fake('public'); + $offer = $this->makeOffer($this->makeProvider()); + + $this->actingAs($this->makeProvider()) + ->postJson("/api/provider/offers/{$offer->id}/cover", ['image' => $this->fakeImage()]) + ->assertStatus(403); + } + + public function test_delete_cover_removes_file(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + $offer = $this->makeOffer($provider); + + $this->actingAs($provider) + ->postJson("/api/provider/offers/{$offer->id}/cover", ['image' => $this->fakeImage()]); + $offer->refresh(); + $oldPath = $offer->cover_image; + + $this->actingAs($provider) + ->deleteJson("/api/provider/offers/{$offer->id}/cover") + ->assertOk(); + + Storage::disk('public')->assertMissing($oldPath); + $this->assertNull($offer->fresh()->cover_image); + } + + public function test_delete_cover_when_no_cover_is_ok(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + $offer = $this->makeOffer($provider); + + $this->actingAs($provider) + ->deleteJson("/api/provider/offers/{$offer->id}/cover") + ->assertOk(); + } + + public function test_delete_cover_forbidden_for_other_provider(): void + { + Storage::fake('public'); + $offer = $this->makeOffer($this->makeProvider()); + + $this->actingAs($this->makeProvider()) + ->deleteJson("/api/provider/offers/{$offer->id}/cover") + ->assertStatus(403); + } + + public function test_upload_gallery_image_success(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + $offer = $this->makeOffer($provider); + + $this->actingAs($provider) + ->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]) + ->assertStatus(201); + + $this->assertDatabaseCount('course_images', 1); + } + + public function test_gallery_max_3_images(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + $offer = $this->makeOffer($provider); + + for ($i = 0; $i < 3; $i++) { + $this->actingAs($provider) + ->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]); + } + + $this->actingAs($provider) + ->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]) + ->assertStatus(422); + } + + public function test_gallery_sort_order_increments(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + $offer = $this->makeOffer($provider); + + $r1 = $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]); + $r2 = $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]); + + $this->assertEquals(1, $r1->json('data.sort_order')); + $this->assertEquals(2, $r2->json('data.sort_order')); + } + + public function test_upload_image_forbidden_for_other_provider(): void + { + Storage::fake('public'); + $offer = $this->makeOffer($this->makeProvider()); + + $this->actingAs($this->makeProvider()) + ->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]) + ->assertStatus(403); + } + + public function test_delete_gallery_image_removes_file(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + $offer = $this->makeOffer($provider); + + $res = $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]); + $imgId = $res->json('data.id'); + $path = CourseImage::find($imgId)->image_path; + + $this->actingAs($provider)->deleteJson("/api/provider/images/{$imgId}")->assertOk(); + + $this->assertDatabaseMissing('course_images', ['id' => $imgId]); + Storage::disk('public')->assertMissing($path); + } + + public function test_delete_image_forbidden_for_other_provider(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + $offer = $this->makeOffer($provider); + + $res = $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]); + $imgId = $res->json('data.id'); + + $this->actingAs($this->makeProvider()) + ->deleteJson("/api/provider/images/{$imgId}") + ->assertStatus(403); + } + + public function test_deleting_offer_removes_storage_directory(): void + { + Storage::fake('public'); + $provider = $this->makeProvider(); + $offer = $this->makeOffer($provider); + + $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/cover", ['image' => $this->fakeImage()]); + $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]); + + $offerId = $offer->id; + $offer->delete(); + + Storage::disk('public')->assertDirectoryEmpty("offers/{$offerId}"); + } +} +