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}");
+ }
+}
+