feat:實作課程圖片上傳 — 封面 + 相簿管理

後端:
- 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:有封面顯示 <img>,無封面顯示漸層佔位
- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 03:54:45 +08:00
parent 81a9f84b26
commit 4baa4cb52b
20 changed files with 1048 additions and 11 deletions
@@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\CourseImage;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class CourseImageController extends Controller
{
private function validateImage(Request $request): void
{
$request->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' => '圖片已刪除']);
}
}
@@ -32,9 +32,11 @@ class DivingOfferController extends Controller
$paginated = $query->paginate($perPage); $paginated = $query->paginate($perPage);
$items = collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false));
return response()->json([ return response()->json([
'status' => true, 'status' => true,
'data' => $paginated->items(), 'data' => $items,
'meta' => [ 'meta' => [
'total' => $paginated->total(), 'total' => $paginated->total(),
'per_page' => $paginated->perPage(), 'per_page' => $paginated->perPage(),
@@ -46,18 +48,29 @@ class DivingOfferController extends Controller
public function show(int $id) public function show(int $id)
{ {
$offer = DivingOffer::find($id); $offer = DivingOffer::with('courseImages')->find($id);
if (!$offer) { if (!$offer) {
return response()->json([ return response()->json(['status' => false, 'message' => '課程不存在'], 404);
'status' => false,
'message' => '課程不存在',
], 404);
} }
return response()->json([ return response()->json(['status' => true, 'data' => $this->formatOffer($offer, true)]);
'status' => true, }
'data' => $offer,
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;
} }
} }
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class CourseImage extends Model
{
public $timestamps = false;
const CREATED_AT = 'created_at';
protected $fillable = [
'diving_offer_id',
'image_path',
'sort_order',
];
protected $casts = [
'sort_order' => 'integer',
'created_at' => 'datetime',
];
public function divingOffer()
{
return $this->belongsTo(DivingOffer::class);
}
public function getUrlAttribute(): string
{
return Storage::disk('public')->url($this->image_path);
}
}
+21
View File
@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class DivingOffer extends Model class DivingOffer extends Model
{ {
@@ -22,6 +23,7 @@ class DivingOffer extends Model
'description', 'description',
'tag', 'tag',
'region', 'region',
'cover_image',
]; ];
protected $casts = [ protected $casts = [
@@ -31,11 +33,30 @@ class DivingOffer extends Model
'reviews'=> 'integer', '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() public function schedules()
{ {
return $this->hasMany(CourseSchedule::class, 'diving_offer_id'); return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
} }
public function courseImages()
{
return $this->hasMany(CourseImage::class)->orderBy('sort_order');
}
public function reviews() public function reviews()
{ {
return $this->hasMany(Review::class); return $this->hasMany(Review::class);
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->string('cover_image', 500)->nullable()->after('description');
});
}
public function down(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->dropColumn('cover_image');
});
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('course_images', function (Blueprint $table) {
$table->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');
}
};
+4
View File
@@ -110,6 +110,10 @@ fi
echo "✅ CFDivePlatform 初始化完成!" echo "✅ CFDivePlatform 初始化完成!"
# 建立 storage symlink
echo "🔗 建立 storage symlink..."
php artisan storage:link --force || true
# 啟動 cron daemonLaravel Scheduler # 啟動 cron daemonLaravel Scheduler
echo "⏰ 啟動 Laravel Scheduler cron..." echo "⏰ 啟動 Laravel Scheduler cron..."
service cron start || cron || true service cron start || cron || true
+27
View File
@@ -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}`)
}
+11 -1
View File
@@ -9,7 +9,17 @@ defineProps({
:to="`/courses/${offer.id}`" :to="`/courses/${offer.id}`"
class="bg-white rounded-2xl shadow hover:shadow-lg transition overflow-hidden flex flex-col" class="bg-white rounded-2xl shadow hover:shadow-lg transition overflow-hidden flex flex-col"
> >
<div class="bg-ocean-700 h-40 flex items-center justify-center text-white text-5xl">🤿</div> <div class="h-40 overflow-hidden">
<img
v-if="offer.cover_image_url"
:src="offer.cover_image_url"
:alt="offer.title"
class="w-full h-full object-cover"
/>
<div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-5xl">
🤿
</div>
</div>
<div class="p-4 flex flex-col gap-2 flex-1"> <div class="p-4 flex flex-col gap-2 flex-1">
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
+25 -1
View File
@@ -125,7 +125,31 @@ async function submitBooking() {
返回課程列表 返回課程列表
</RouterLink> </RouterLink>
<div class="bg-ocean-700 rounded-2xl h-56 flex items-center justify-center text-white text-7xl mb-6">🤿</div> <!-- 封面大圖 -->
<div class="rounded-2xl h-64 overflow-hidden mb-4">
<img
v-if="offer.cover_image_url"
:src="offer.cover_image_url"
:alt="offer.title"
class="w-full h-full object-cover"
/>
<div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-7xl">
🤿
</div>
</div>
<!-- 相簿縮圖列 -->
<div v-if="offer.images && offer.images.length > 0" class="flex gap-2 mb-6">
<a
v-for="img in offer.images"
:key="img.id"
:href="img.url"
target="_blank"
class="w-24 h-20 rounded-xl overflow-hidden shrink-0 border-2 border-transparent hover:border-ocean-400 transition"
>
<img :src="img.url" class="w-full h-full object-cover" />
</a>
</div>
<div class="flex flex-wrap gap-2 mb-3"> <div class="flex flex-wrap gap-2 mb-3">
<span <span
@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import coachApi from '../../api/coachAxios' import coachApi from '../../api/coachAxios'
import { uploadCover, deleteCover, uploadImage, deleteImage } from '../../api/courseImageApi'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -25,6 +26,12 @@ const form = ref({
description: '', description: '',
}) })
// 圖片管理
const coverUrl = ref(null)
const galleryImgs = ref([])
const imgUploading = ref(false)
const imgError = ref('')
onMounted(async () => { onMounted(async () => {
if (!isEdit.value) return if (!isEdit.value) return
loading.value = true loading.value = true
@@ -41,6 +48,8 @@ onMounted(async () => {
badges: Array.isArray(o.badges) ? o.badges.join(', ') : (o.badges || ''), badges: Array.isArray(o.badges) ? o.badges.join(', ') : (o.badges || ''),
description: o.description || '', description: o.description || '',
} }
coverUrl.value = o.cover_image_url || null
galleryImgs.value = o.images || []
} catch (e) { } catch (e) {
error.value = e.response?.data?.message || '無法載入課程資料' error.value = e.response?.data?.message || '無法載入課程資料'
} finally { } 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() { async function submit() {
errors.value = {} errors.value = {}
error.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" /> 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" />
</div> </div>
<!-- 圖片管理僅編輯模式 -->
<div v-if="isEdit" class="border border-gray-200 rounded-xl p-5 space-y-5">
<h3 class="text-sm font-semibold text-gray-700">課程圖片</h3>
<p v-if="imgError" class="text-red-500 text-xs">{{ imgError }}</p>
<!-- 封面 -->
<div>
<label class="block text-xs text-gray-500 mb-2">封面圖片1 </label>
<div class="flex items-center gap-4">
<div class="w-32 h-24 rounded-lg overflow-hidden bg-gray-100 shrink-0">
<img v-if="coverUrl" :src="coverUrl" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center text-gray-400 text-2xl">🤿</div>
</div>
<div class="flex flex-col gap-2">
<label class="cursor-pointer text-xs bg-gray-900 text-white px-3 py-1.5 rounded-lg hover:bg-gray-700 transition text-center">
{{ coverUrl ? '更換封面' : '上傳封面' }}
<input type="file" accept="image/jpeg,image/png,image/webp" class="hidden" @change="onCoverChange" :disabled="imgUploading" />
</label>
<button v-if="coverUrl" @click="onDeleteCover" type="button"
class="text-xs text-red-500 hover:text-red-700 underline">刪除封面</button>
</div>
</div>
</div>
<!-- 相簿 -->
<div>
<label class="block text-xs text-gray-500 mb-2">相簿圖片最多 3 </label>
<div class="flex gap-3 flex-wrap">
<div v-for="img in galleryImgs" :key="img.id" class="relative w-24 h-20 rounded-lg overflow-hidden">
<img :src="img.url" class="w-full h-full object-cover" />
<button @click="onDeleteImage(img)" type="button"
class="absolute top-1 right-1 bg-black/60 text-white rounded-full w-5 h-5 text-xs flex items-center justify-center hover:bg-black/80">
</button>
</div>
<label v-if="galleryImgs.length < 3"
class="w-24 h-20 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center cursor-pointer hover:border-ocean-400 transition text-gray-400 text-xl">
+
<input type="file" accept="image/jpeg,image/png,image/webp" class="hidden" @change="onGalleryChange" :disabled="imgUploading" />
</label>
</div>
<p v-if="imgUploading" class="text-xs text-gray-400 mt-1">上傳中...</p>
</div>
</div>
<div class="flex gap-3 justify-end pt-2"> <div class="flex gap-3 justify-end pt-2">
<RouterLink to="/coach/dashboard" <RouterLink to="/coach/dashboard"
class="px-5 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition"> class="px-5 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-11
@@ -0,0 +1,3 @@
# course-images
課程圖片上傳:封面圖 + 相簿最多 3 張,本地 public diskDocker volume 持久化
@@ -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 diskURL 可直接用於 `<img src>`
- 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 volumeNginx 透過 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 正確
@@ -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`
@@ -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** 系統回傳 422message:「只支援 jpeg、png、webp 格式」
#### Scenario: 檔案大小驗證
- **WHEN** 上傳檔案超過 2MB2048KB
- **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` 回傳可直接用於 `<img src>` 的完整 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 不變)
@@ -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`fillablediving_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)`(皆使用 coachAxiosContent-Type: multipart/form-data
## 6. 前端:課程卡封面顯示
- [x] 6.1 [前端] 更新 `frontend/src/components/CourseCard.vue`:有 `cover_image_url` 時顯示 `<img>`,無時顯示漸層佔位(保留 🤿 emoji 或 ocean 漸層背景)
## 7. 前端:課程詳情頁圖片展示
- [x] 7.1 [前端] 更新 `frontend/src/views/CourseDetailView.vue`:頂部大圖改為封面(有封面顯示圖片,無封面顯示漸層佔位)
- [x] 7.2 [前端] 相簿縮圖列:`images.length > 0` 時在封面下方顯示最多 3 張縮圖橫列,點擊放大(用 `<img>` 原始連結即可,不需 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 消失
@@ -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** 上傳檔案超過 2MB2048KB
- **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` 回傳可直接用於 `<img src>` 的完整 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 目錄不受容器重建影響
+6
View File
@@ -10,6 +10,7 @@ use App\Http\Controllers\API\MemberBookingController;
use App\Http\Controllers\API\ReviewController; use App\Http\Controllers\API\ReviewController;
use App\Http\Controllers\API\AdminReviewController; use App\Http\Controllers\API\AdminReviewController;
use App\Http\Controllers\API\AdminBookingController; use App\Http\Controllers\API\AdminBookingController;
use App\Http\Controllers\API\CourseImageController;
use App\Http\Controllers\API\AdminStatsController; use App\Http\Controllers\API\AdminStatsController;
use App\Http\Controllers\API\AdminUserController; use App\Http\Controllers\API\AdminUserController;
use App\Http\Controllers\API\AdminOfferController; 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::get('/offers/{id}', [ProviderOfferController::class, 'show']);
Route::put('/offers/{id}', [ProviderOfferController::class, 'update']); Route::put('/offers/{id}', [ProviderOfferController::class, 'update']);
Route::delete('/offers/{id}', [ProviderOfferController::class, 'destroy']); 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::get('/schedules', [ScheduleController::class, 'index']);
Route::post('/schedules', [ScheduleController::class, 'store']); Route::post('/schedules', [ScheduleController::class, 'store']);
+227
View File
@@ -0,0 +1,227 @@
<?php
namespace Tests\Feature;
use App\Models\CourseImage;
use App\Models\DivingOffer;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class CourseImageTest extends TestCase
{
use RefreshDatabase;
private function makeProvider(): User
{
return User::factory()->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}");
}
}