feat:實作 Coach Portal — 教練後台課程管理

後端:
- Migration:diving_offers 新增 provider_id(nullable FK)
- Migration:users.role ENUM 加入 provider 值
- Migration:diving_offers.spot 改為 nullable
- AuthController:registerProvider business_name 改為選填
- AuthController:updateProviderProfile 補上 certifications / dive_sites / services / facilities / website / social_media
- ProviderOfferController:教練課程 CRUD(index/show/store/update/destroy),實作 provider_id 所有權不變式(404 → 403 兩步驟)

前端(frontend/):
- coachAuth store、coachAxios(獨立於 member auth)
- /coach/* 路由群組 + beforeEach guard
- CoachNavBar、CoachLayout(教練頁隱藏會員 NavBar)
- LoginView、RegisterView、DashboardView(表格 + 刪除確認)
- OfferFormView(新增/編輯共用)、ProfileView

OpenSpec:
- openspec/config.yaml 補入專案 context 與 rules
- 新增 specs:coach-offers-api / coach-portal-ui / provider-auth
- 更新 spec:diving-offers-api 加入 provider_id
- 歸檔:openspec/changes/archive/2026-05-10-coach-portal

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 03:34:14 +08:00
parent 550e2fc97a
commit da48a3652d
31 changed files with 1890 additions and 27 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-09
@@ -0,0 +1,152 @@
## Context
後端 `AuthController` 已有完整的 Provider Auth 方法實作(login / logout / profile / register / update),經審查後大部分可直接沿用,僅需對 `registerProvider``updateProviderProfile` 做小幅欄位調整以對齊 Coach Portal 使用情境。`diving_offers` 表無 `provider_id`,課程與教練無法關聯。前端(`frontend/`)已有 Member Portal 的架構(Pinia、Vue Router、Axios、Tailwind),Coach Portal 將整合進同一個 SPA,以 `/coach/*` 路由群組區分。
## Goals / Non-Goals
**Goals:**
- 教練可以申請註冊帳號(含工作室/個人資訊)
- 教練可以用獨立帳號登入後台
- 教練可以 CRUD 管理自己上架的潛水課程
- 教練可以讀取與更新個人資料
- 課程與建立者(provider)綁定,不同教練只能看/改自己的課程
**Non-Goals:**
- 訂單 / 預約系統
- 課程圖片上傳(MVP 用 emoji 佔位)
- 教練與會員的配對管理
- 課程審核流程(Admin 功能)
## Decisions
### D1Coach Portal 整合進現有 SPA,不另開 repo
**決定**`/coach/*` 路由加進 `frontend/src/router/index.js`,與 Member Portal 共用同一個 Vue app。
**理由**frontend/ 已在同一個 repo,共用 Tailwind、Axios instance、router 基礎設施。分開 repo 收益不大,反而增加維護成本。
---
### D2:獨立的 coachAuth Pinia Store
**決定**:新增 `src/stores/coachAuth.js`,與現有 `auth.js`member)完全分開。localStorage key 用 `coach_token` / `coach_user` 區分。
**理由**:同一個瀏覽器可能同時開著會員頁和教練後台(不同 tab)。共用 store 會互相污染登入狀態。
---
### D3Provider Auth / Profile 沿用現有 AuthController,只做必要調整
**決定**:沿用現有 `AuthController``registerProvider``loginProvider``logoutProvider``providerProfile``updateProviderProfile` 方法,僅針對教練情境補充欄位與調整驗證規則。
**理由**:現有路由與主要邏輯已存在,本次以最小修改滿足 Coach Portal 需求,避免重複開發。
---
### D4:課程 CRUD — 新增 ProviderOfferController
**決定**:新增獨立的 `ProviderOfferController`,處理教練的課程 CRUD。`index()` 只返回當前 provider 的課程;`store()` 強制將 `provider_id` 設為 `auth()->id()``show()``update()``destroy()` 則驗證課程擁有權。
**理由**:與公開的 `DivingOfferController` 職責分開,避免授權邏輯混雜。
**Invariant — provider_id 所有權(實用優先)**
單一課程操作端點(show / update / destroy)依序執行兩步驟,不可合併:
1. `DivingOffer::find($id)` → null 時回傳 **404**
2. `offer->provider_id !== auth()->id()` → 回傳 **403**
此設計會洩漏資源存在性,為刻意取捨:`diving_offers` 使用自增整數 ID,資源存在性本可枚舉,安全遮蔽收益有限;而對教練而言,明確區分「課程不存在」與「無權限」有實際操作價值。
`store()` 補充規則:強制將 `provider_id` 設為 `auth()->id()`,忽略 request body 中任何傳入值。
---
### D5diving_offers.provider_id — Nullable Migration
**決定**`provider_id``nullable` 外鍵,現有測試資料不受影響。
**理由**:現有 6 筆手動塞入的課程 `provider_id` 為 null,保留以免資料遺失。之後可用 seeder 補上。
---
## Contracts
### API Schema
#### `POST /api/provider/register`
```
Body: { name, email, password, password_confirmation, phone?,
business_name?, description?, contact_phone?, contact_email?, address? }
Response 201: { "status": true, "message": "服務提供者註冊成功", "data": { "user": {...}, "token": "...", "token_type": "Bearer" } }
Response 422: { "status": false, "message": "驗證失敗", "errors": {...} }
```
#### `POST /api/provider/login`
```
Body: { "email": "...", "password": "..." }
Response 200: { "status": true, "data": { "user": {...}, "token": "...", "token_type": "Bearer" } }
Response 401: { "status": false, "message": "帳號或密碼錯誤" }
```
#### `GET /api/provider/offers`(需 Bearer tokenrole=provider
```
Response 200: { "status": true, "data": [...offers], "meta": { total, per_page, current_page, last_page } }
```
#### `GET /api/provider/offers/{id}`(需 Bearer tokenrole=provider
```
Response 200: { "status": true, "data": { ...offer } }
Response 403: { "status": false, "message": "無權限查看此課程" }
Response 404: { "status": false, "message": "課程不存在" }
```
#### `POST /api/provider/offers`
```
Body: { title, location, spot, price, region, tag, badges (array), description }
Response 201: { "status": true, "data": { ...offer } }
Response 422: { "status": false, "message": "...", "errors": {...} }
```
#### `PUT /api/provider/offers/{id}`
```
Body: 同 POST(部分欄位可選)
Response 200: { "status": true, "data": { ...offer } }
Response 403: { "status": false, "message": "無權限修改此課程" }
Response 404: { "status": false, "message": "課程不存在" }
```
#### `DELETE /api/provider/offers/{id}`
```
Response 200: { "status": true, "message": "課程已刪除" }
Response 403: { "status": false, "message": "無權限刪除此課程" }
Response 404: { "status": false, "message": "課程不存在" }
```
---
## Risks / Trade-offs
| 風險 | 緩解策略 |
|------|----------|
| `AuthController` 已很龐大,繼續加方法會更難維護 | MVP 接受,下一個 change 可拆分成 `ProviderAuthController` |
| 同一 SPA 混合 Member 和 Coach 路由,bundle 變大 | 所有頁面已用動態 import(`() => import(...)`),不影響首次載入 |
| `provider_id` nullable 導致公開課程列表混有無主課程 | 公開 API 不過濾 null,視為平台示範課程;Coach 的列表 API 只返回自己的 |
## Open Questions
- [x] `ProviderProfile``CoachProfile` 兩個 model 並存,目前 provider login 應該用哪個 profile?→ 決定統一使用 `ProviderProfile``CoachProfile` 暫時忽略(legacy
## 現有方法審查結果(實作前確認)
| 方法 | 狀態 | 說明 |
|------|------|------|
| `loginProvider` | ✅ 直接可用 | role 驗證、token、is_active 檢查皆完整 |
| `logoutProvider` | ✅ 直接可用 | role 檢查 + token 撤銷正確 |
| `providerProfile` (GET) | ✅ 直接可用 | 回傳 user + providerProfile |
| `registerProvider` | ⚠️ 小調整 | `business_name` 改為 nullable(單人教練不一定有業者名稱)|
| `updateProviderProfile` | ⚠️ 補欄位 | 補上 certifications / dive_sites / services / facilities / website / social_media |
**ProviderProfile 欄位使用策略:**
- 教練可自行編輯:business_name、description、certifications、dive_sites、services、facilities、contact_person、contact_phone、contact_email、address、business_hours、website、social_media
- 系統/Admin 管理(前端唯讀顯示):is_verified、rating、is_active、logo_url、banner_url
@@ -0,0 +1,38 @@
## Why
會員端課程列表目前依賴手動塞入的測試資料,平台無法規模化運作。需要 Coach Portal 讓教練能自行上架、編輯、下架課程,使平台內容自給自足。
## What Changes
- **後端**`diving_offers` 表新增 `provider_id` 欄位,綁定課程與教練
- **後端**:補完 Provider Auth APIregister / login / logout / profile CRUD
- **後端**:新增 Coach 課程管理 APICRUD,需 provider 角色驗證)
- **前端**:在現有 Vue 3 SPA 新增 `/coach/*` 路由群組,整合教練登入與課程管理介面
- **修改** `diving-offers-api`:公開課程列表 API 新增 `provider_id` 欄位於 response,供未來關聯展示使用
## Capabilities
### New Capabilities
- `provider-auth`:教練帳號的註冊、登入、登出、個人資料讀取與更新 API
- `coach-offers-api`:教練專屬課程管理 API(列出自己課程、新增、更新、刪除)
- `coach-portal-ui`:教練後台前端介面(登入、課程 Dashboard、新增/編輯表單、個人資料頁)
### Modified Capabilities
- `diving-offers-api``diving_offers` 資料表新增 `provider_id` 欄位,response 加入此欄位(向後相容,nullable)
## Impact
**後端**
- 新增 migration`diving_offers.provider_id`
- 補完 `AuthController` 中 Provider 相關方法(現有路由佔位但方法未實作)
- 新增 `ProviderOfferController`
**前端(frontend/ 目錄)**
- 新增 `src/stores/coachAuth.js`
- 新增 `src/views/coach/` 目錄下各頁面
- `src/router/index.js` 新增 `/coach/*` 路由與 guard
**資料庫**
- `diving_offers` 表結構變更(新增 nullable 欄位,不影響現有資料)
@@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: provider_id 所有權不變式
對單一課程操作端點(show / update / destroy),系統 MUST 依序執行:先以 id 查找課程(不存在回 404),再比對 provider_id(不符回 403)。兩步驟不可合併為單一 WHERE 查詢。`store()` MUST 強制將 `provider_id` 設為 `auth()->id()`,忽略 request body 傳入值。
#### Scenario: 課程不存在回傳 404
- **WHEN** 指定 id 的課程不存在於資料庫
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
#### Scenario: 課程存在但非本人回傳 403
- **WHEN** 課程存在(id 有效)但 `offer.provider_id !== auth()->id()`
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限…" }`
#### Scenario: store 強制設定 provider_id
- **WHEN** 教練送出新增課程請求,body 中包含任意 provider_id 值
- **THEN** 系統忽略該值,`offer.provider_id` 固定為 `auth()->id()`
---
### Requirement: 教練課程列表
後端 SHALL 提供 `GET /api/provider/offers`(需 Bearer tokenrole=provider),回傳當前登入教練自己建立的課程,支援分頁。
#### Scenario: 取得自己的課程列表
- **WHEN** 已登入教練送出 GET 請求
- **THEN** 回傳 HTTP 200,只包含 `provider_id = auth()->id()` 的課程,含分頁 meta
#### Scenario: 無課程時回傳空陣列
- **WHEN** 教練尚未建立任何課程
- **THEN** 回傳 HTTP 200`{ status: true, data: [], meta: { total: 0, ... } }`
---
### Requirement: 教練課程詳情
後端 SHALL 提供 `GET /api/provider/offers/{id}`(需 Bearer tokenrole=provider),回傳單一課程完整資料,只允許查看自己建立的課程。
#### Scenario: 取得自己的課程詳情
- **WHEN** 已登入教練送出 `GET /api/provider/offers/1`,且該課程 `provider_id = auth()->id()`
- **THEN** 回傳 HTTP 200`{ status: true, data: { ...offer } }`
#### Scenario: 查看他人課程
- **WHEN** 課程存在但 `provider_id !== auth()->id()`
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限查看此課程" }`
#### Scenario: 課程不存在
- **WHEN** 指定 id 的課程不存在
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
---
### Requirement: 教練新增課程
後端 SHALL 提供 `POST /api/provider/offers`(需 Bearer token),建立新課程並自動設定 `provider_id` 為當前登入教練。
#### Scenario: 新增課程成功
- **WHEN** 教練送出包含 title / location / spot / price / region 的合法資料
- **THEN** 回傳 HTTP 201`{ status: true, data: { ...offer, provider_id: <coach_id> } }`
#### Scenario: 缺少必填欄位
- **WHEN** 教練送出缺少 title 或 price 的資料
- **THEN** 回傳 HTTP 422`{ status: false, message: "...", errors: { field: [...] } }`
---
### Requirement: 教練更新課程
後端 SHALL 提供 `PUT /api/provider/offers/{id}`(需 Bearer token),更新指定課程,只允許修改自己建立的課程。
#### Scenario: 更新自己的課程
- **WHEN** 教練送出合法更新資料且 offer.provider_id === auth()->id()
- **THEN** 回傳 HTTP 200`{ status: true, data: { ...updated_offer } }`
#### Scenario: 嘗試更新他人課程
- **WHEN** offer.provider_id !== auth()->id()
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限修改此課程" }`
#### Scenario: 課程不存在
- **WHEN** 指定 id 的課程不存在
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
---
### Requirement: 教練刪除課程
後端 SHALL 提供 `DELETE /api/provider/offers/{id}`(需 Bearer token),刪除指定課程,只允許刪除自己建立的課程。
#### Scenario: 刪除自己的課程
- **WHEN** offer.provider_id === auth()->id()
- **THEN** 回傳 HTTP 200`{ status: true, message: "課程已刪除" }`,資料庫記錄移除
#### Scenario: 嘗試刪除他人課程
- **WHEN** offer.provider_id !== auth()->id()
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限刪除此課程" }`
@@ -0,0 +1,102 @@
## ADDED Requirements
### Requirement: 教練註冊頁
前端 SHALL 提供 `/coach/register` 頁面,供教練填寫帳號資訊與業者資料後申請帳號,成功後導向 `/coach/login`
#### Scenario: 註冊成功
- **WHEN** 教練填入必填欄位(name / email / password / password_confirmation)並送出
- **THEN** 呼叫 `POST /api/provider/register`,成功後導向 `/coach/login?registered=1`,顯示「註冊成功,請登入」提示
#### Scenario: Email 重複
- **WHEN** 送出已存在的 email
- **THEN** 頁面顯示後端回傳的錯誤訊息,不跳轉
#### Scenario: 密碼不一致
- **WHEN** password 與 password_confirmation 不一致
- **THEN** 後端回傳 422,頁面顯示欄位錯誤提示
#### Scenario: business_name 為選填
- **WHEN** 教練不填寫工作室名稱直接送出
- **THEN** 正常完成註冊,business_name 存為 null
---
### Requirement: 教練登入頁
前端 SHALL 提供 `/coach/login` 頁面,供教練以 email/password 登入,成功後導向 `/coach/dashboard`
#### Scenario: 登入成功
- **WHEN** 教練填入正確帳密並送出
- **THEN** 呼叫 `POST /api/provider/login`token 存入 coachAuth storelocalStorage key: coach_token),導向 `/coach/dashboard`
#### Scenario: 登入失敗
- **WHEN** 帳密錯誤
- **THEN** 頁面顯示錯誤訊息,不跳轉
#### Scenario: 已登入教練訪問登入頁
- **WHEN** coachAuth.isLoggedIn 為 true 時訪問 `/coach/login`
- **THEN** 自動導向 `/coach/dashboard`
---
### Requirement: 課程 Dashboard
前端 SHALL 提供 `/coach/dashboard` 頁面(需教練登入),顯示自己的課程列表,並提供新增、編輯、刪除操作入口。
#### Scenario: 載入課程列表
- **WHEN** 已登入教練訪問 Dashboard
- **THEN** 呼叫 `GET /api/provider/offers`,以表格或卡片渲染課程(標題、地點、價格、狀態)
#### Scenario: 無課程時顯示空狀態
- **WHEN** 教練尚無課程
- **THEN** 顯示「尚無課程,立即新增第一堂課」提示與新增按鈕
#### Scenario: 刪除課程確認
- **WHEN** 教練點擊刪除按鈕
- **THEN** 顯示確認提示,確認後呼叫 `DELETE /api/provider/offers/{id}`,成功後更新列表
---
### Requirement: 新增課程表單
前端 SHALL 提供 `/coach/offers/new` 頁面,教練填寫課程資訊後送出新增。
#### Scenario: 新增課程成功
- **WHEN** 教練填入所有必填欄位並送出
- **THEN** 呼叫 `POST /api/provider/offers`,成功後導向 `/coach/dashboard` 並顯示成功提示
#### Scenario: 表單驗證失敗
- **WHEN** 必填欄位(title / location / price)為空
- **THEN** 前端顯示欄位錯誤提示,不送出 API
---
### Requirement: 編輯課程表單
前端 SHALL 提供 `/coach/offers/:id/edit` 頁面,預填現有課程資料供教練修改。
#### Scenario: 載入課程資料並編輯
- **WHEN** 教練訪問編輯頁
- **THEN** 從 Dashboard 傳入或呼叫 API 取得課程資料,預填表單,送出後呼叫 `PUT /api/provider/offers/{id}`,成功後返回 Dashboard
#### Scenario: 無權限編輯
- **WHEN** API 回傳 403
- **THEN** 頁面顯示「無權限修改此課程」並返回 Dashboard
---
### Requirement: 教練個人資料頁
前端 SHALL 提供 `/coach/profile` 頁面(需教練登入),顯示並允許更新教練基本資訊與專業資料。
#### Scenario: 讀取並更新資料
- **WHEN** 教練訪問個人資料頁
- **THEN** 呼叫 `GET /api/provider/profile`,顯示 name / email / bio / expertise / certification,儲存時呼叫 `PUT /api/provider/profile`
---
### Requirement: Coach 路由守衛
前端 SHALL 對所有 `/coach/*` 路由(login 除外)加上 navigation guard,未登入時導向 `/coach/login`
#### Scenario: 未登入訪問 Dashboard
- **WHEN** 未登入使用者直接訪問 `/coach/dashboard`
- **THEN** 自動導向 `/coach/login`
#### Scenario: 登出
- **WHEN** 教練點擊登出
- **THEN** 呼叫 `POST /api/provider/logout`,清除 coach_token / coach_user,導向 `/coach/login`
@@ -0,0 +1,24 @@
## MODIFIED Requirements
### Requirement: 課程列表 API
後端 SHALL 提供公開的 `GET /api/diving-offers` endpoint,回傳分頁的潛水課程列表,支援關鍵字搜尋與篩選,無需認證即可存取。response 中每筆課程包含 `provider_id` 欄位(可為 null)。
#### Scenario: 取得全部課程列表
- **WHEN** 客戶端發送 `GET /api/diving-offers` 且不帶任何參數
- **THEN** 回傳 HTTP 200body 包含 `{ data: [...], meta: { total, per_page, current_page } }`,預設每頁 12 筆,每筆資料含 `provider_id`
#### Scenario: 依關鍵字搜尋課程
- **WHEN** 客戶端發送 `GET /api/diving-offers?q=墾丁`
- **THEN** 回傳 `title``location` 包含「墾丁」的課程列表
#### Scenario: 依地區篩選課程
- **WHEN** 客戶端發送 `GET /api/diving-offers?region=南部`
- **THEN** 只回傳 `region` 欄位等於「南部」的課程
#### Scenario: 依標籤篩選課程
- **WHEN** 客戶端發送 `GET /api/diving-offers?tag=初學者`
- **THEN** 只回傳 `tag` 欄位包含「初學者」的課程
#### Scenario: 分頁參數
- **WHEN** 客戶端發送 `GET /api/diving-offers?page=2&per_page=6`
- **THEN** 回傳第 2 頁資料,每頁 6 筆,`meta` 包含正確的分頁資訊
@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: 教練帳號登入
後端 SHALL 提供 `POST /api/provider/login`,驗證 email/password 並回傳 Sanctum Bearer token,僅限 role=provider 帳號。
#### Scenario: 正確帳密登入成功
- **WHEN** 教練送出正確的 email 與 password
- **THEN** 回傳 HTTP 200,包含 `{ status: true, data: { user, token, token_type: "Bearer" } }`
#### Scenario: 錯誤帳密登入失敗
- **WHEN** 教練送出錯誤的 email 或 password
- **THEN** 回傳 HTTP 401`{ status: false, message: "帳號或密碼錯誤" }`
#### Scenario: 會員帳號無法用教練登入
- **WHEN** role=member 的帳號嘗試呼叫 `/api/provider/login`
- **THEN** 回傳 HTTP 403`{ status: false, message: "此帳號非教練角色" }`
---
### Requirement: 教練帳號註冊
後端 SHALL 提供 `POST /api/provider/register`,建立 role=provider 的 User 與對應 ProviderProfile。
#### Scenario: 新帳號註冊成功
- **WHEN** 送出有效的 name / email / password / password_confirmation
- **THEN** 回傳 HTTP 201`{ status: true, data: { user } }`
#### Scenario: Email 重複
- **WHEN** 送出已存在的 email
- **THEN** 回傳 HTTP 422`{ status: false, message: "此 Email 已被使用" }`
---
### Requirement: 教練登出
後端 SHALL 提供 `POST /api/provider/logout`(需 Bearer token),撤銷當前 token。
#### Scenario: 登出成功
- **WHEN** 已登入教練送出登出請求
- **THEN** 回傳 HTTP 200`{ status: true, message: "已登出" }`token 失效
---
### Requirement: 教練個人資料讀取
後端 SHALL 提供 `GET /api/provider/profile`(需 Bearer token),回傳教練基本資訊與 ProviderProfile。
#### Scenario: 取得個人資料
- **WHEN** 已登入教練送出 GET 請求
- **THEN** 回傳 HTTP 200,包含 `{ id, name, email, role, profile: { bio, expertise, certification, avatar } }`
---
### Requirement: 教練個人資料更新
後端 SHALL 提供 `PUT /api/provider/profile`(需 Bearer token),更新教練基本資訊與 ProviderProfile。
#### Scenario: 更新成功
- **WHEN** 教練送出合法的更新資料
- **THEN** 回傳 HTTP 200`{ status: true, message: "資料已更新", data: { ...profile } }`
@@ -0,0 +1,84 @@
## 1. [後端] 資料庫 Migration
- [x] 1.1 建立 migration`diving_offers` 新增 `provider_id` 欄位(`unsignedBigInteger` nullable,外鍵關聯 `users.id`onDelete set null
- [x] 1.2 執行 `docker exec cfdive-app php artisan migrate`,確認欄位新增成功
- [x] 1.3 更新 `DivingOffer` Model`$fillable` 加入 `provider_id`
## 2. [後端] Provider Auth API 調整
- [x] 2.1 修改 `registerProvider()`:將 `business_name` 改為 nullable(單人教練不一定有業者名稱),驗證規則從 `required` 改為 `nullable|string|max:255`
- [x] 2.2 `loginProvider()` ✅ 直接可用,不需改動(role 驗證、token、load profile 皆正確)
- [x] 2.3 `logoutProvider()` ✅ 直接可用,不需改動
- [x] 2.4 `providerProfile()` ✅ 直接可用,不需改動
- [x] 2.5 補完 `updateProviderProfile()`:在現有更新邏輯後補上以下欄位的更新處理:
- `certifications`PADI / SSI 等認證資訊)
- `dive_sites`(常駐潛點,逗號分隔字串)
- `services`(提供服務類型)
- `facilities`(設施說明)
- `website`(官網連結)
- `social_media`(社群媒體連結)
- 同時在 Validator 規則加入這六個欄位(皆為 `nullable|string`
- [x] 2.6 用 Postman 驗證:registerbusiness_name 選填)→ login → GET profile → PUT profile(含新增欄位)→ logout
## 3. [後端] Coach 課程管理 API
- [x] 3.1 建立 `ProviderOfferController`,實作 `index()`:只回傳 `provider_id = auth()->id()` 的課程,含分頁
- [x] 3.2 實作 `show($id)``find()` null → 404`provider_id !== auth()->id()` → 403;否則回傳課程資料
- [x] 3.3 實作 `store()`:驗證必填欄位(title / location / spot / price / region),強制將 `provider_id` 設為 `auth()->id()`(忽略 body 傳入值),回傳 201
- [x] 3.4 實作 `update($id)``find()` null → 404`provider_id !== auth()->id()` → 403;更新欄位回傳 200
- [x] 3.5 實作 `destroy($id)``find()` null → 404`provider_id !== auth()->id()` → 403;刪除回傳 200
- [x] 3.6 在 `routes/api.php``provider` middleware group 新增課程路由:GET(index) / GET(show) / POST / PUT / DELETE
- [x] 3.7 用 Postman 驗證:新增 → 列表 → 單筆詳情 → 更新 → 刪除;另測試跨教練操作:存在課程回 403、不存在 ID 回 404
## 4. [前端] coachAuth Store 與基礎設施
- [x] 4.1 建立 `frontend/src/stores/coachAuth.js`:管理 `coach_token` / `coach_user`,實作 `init()` / `setAuth()` / `logout()`
- [x] 4.2 建立 `frontend/src/api/coachAxios.js`:獨立 Axios instancerequest interceptor 讀 `coach_token`
- [x] 4.3 在 `frontend/src/router/index.js` 新增 `/coach/*` 路由群組:login / dashboard / offers/new / offers/:id/edit / profile
- [x] 4.4 `/coach/*`login 除外)加上 beforeEach guard,未登入導向 `/coach/login`
- [x] 4.5 在 `App.vue``onMounted` 加入 `coachAuth.init()`
## 5. [前端] Coach Layout 與導覽
- [x] 5.1 建立 `frontend/src/components/CoachNavBar.vue`:顯示教練姓名、「我的課程」、「個人資料」連結與登出按鈕
- [x] 5.2 建立 `frontend/src/layouts/CoachLayout.vue`:包含 CoachNavBar + `<RouterView>`,供所有 `/coach/*` 頁面使用
## 6. [前端] 教練認證頁面
- [x] 6.1 建立 `frontend/src/views/coach/RegisterView.vue`:帳號資訊 + 業者資訊兩段表單,送出呼叫 `POST /api/provider/register`,成功導向 `/coach/login?registered=1`,失敗顯示欄位錯誤;business_name 選填
- [x] 6.2 建立 `frontend/src/views/coach/LoginView.vue`email/password 表單,送出呼叫 `POST /api/provider/login`,成功存 token 並導向 `/coach/dashboard`,失敗顯示錯誤;若 query 有 `?registered=1` 顯示「註冊成功,請登入」
## 7. [前端] 課程 Dashboard
- [x] 7.1 建立 `frontend/src/views/coach/DashboardView.vue`:掛載時呼叫 `GET /api/provider/offers`,以表格列出課程(標題、地點、價格)
- [x] 7.2 新增「新增課程」按鈕,點擊導向 `/coach/offers/new`
- [x] 7.3 每列新增「編輯」按鈕,點擊導向 `/coach/offers/:id/edit`
- [x] 7.4 每列新增「刪除」按鈕:顯示確認 dialog,確認後呼叫 `DELETE /api/provider/offers/{id}`,成功後重新載入列表
- [x] 7.5 無課程時顯示空狀態提示
## 8. [前端] 課程表單(新增 / 編輯)
- [x] 8.1 建立 `frontend/src/views/coach/OfferFormView.vue`(新增與編輯共用同一個組件,以 route param 判斷模式)
- [x] 8.2 欄位:title(必填)、location(必填)、spot、price(必填)、region、tag、badges(多選或逗號分隔輸入)、description
- [x] 8.3 新增模式:送出呼叫 `POST /api/provider/offers`,成功後導向 Dashboard
- [x] 8.4 編輯模式:掛載時取得課程資料預填,送出呼叫 `PUT /api/provider/offers/{id}`,成功後導向 Dashboard
- [x] 8.5 前端必填欄位驗證(title / location / price 為空時不送出)
## 9. [前端] 教練個人資料頁
- [x] 9.1 建立 `frontend/src/views/coach/ProfileView.vue`:掛載時呼叫 `GET /api/provider/profile`,顯示以下欄位:
- 基本:name、email、phone
- 業者:business_name(工作室/個人教練名稱)、description(自我介紹)
- 專業:certifications(認證)、dive_sites(常駐潛點)、services(服務類型)
- 聯絡:contact_person、contact_phone、contact_email、address、business_hours
- 網路:website、social_media
- 唯讀顯示(不可自改):is_verified、rating
- [x] 9.2 實作編輯表單,送出呼叫 `PUT /api/provider/profile`(包含 task 2.5 補完的新欄位),成功顯示「資料已更新」提示
## 10. [整合測試] 端對端驗證
- [x] 10.1 驗證教練完整認證流程:註冊 → 登入 → 登出 → 重新登入
- [x] 10.2 驗證課程 CRUD:新增 → Dashboard 出現 → 編輯 → 刪除
- [x] 10.3 驗證 route guard:未登入訪問 `/coach/dashboard` 自動跳轉 `/coach/login`
- [x] 10.4 驗證權限隔離:教練 A 無法編輯/刪除教練 B 的課程(API 層回傳 403)
- [x] 10.5 驗證公開課程列表(`/courses`)能看到教練新增的課程
+54 -17
View File
@@ -1,20 +1,57 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
context: |
## 專案:CFDivePlatform
潛水課程媒合平台,連結潛水教練(Provider)與學員(Member)。
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
## 架構
- 後端:Laravel 11 + SanctumBearer token+ MySQL,跑在 Dockercfdive-app / cfdive-nginx:8080
- 前端:Vue 3 + Vite + Tailwind CSS + Pinia + Vue Router 4 + Axios
- 原始碼在 `frontend/`(同一個 repo
- Docker image: cfdive-frontend,對外 port 5173
- DBMySQL 8Docker),ORM 使用 Eloquent
- 部署:docker-compose`docker-compose up --build` 即可啟動全部服務
## 使用者角色
- `member`:一般會員,瀏覽/搜尋課程,Google OAuth 登入
- `provider`:教練/業者,管理自己的課程(CRUD),使用 ProviderProfile
- `admin`:平台管理員,尚未實作
## 已完成模組
- Member Portal`/`、`/courses`、`/login`、`/register`、`/profile`
- Coach Portal`/coach/*`):登入、註冊、課程 CRUD Dashboard、個人資料
- Diving Offers 公開 API`GET /api/diving-offers`、`GET /api/diving-offers/{id}`
- Provider Auth API`/api/provider/login|register|logout|profile`
- Provider Offers API`/api/provider/offers` CRUD,含 provider_id 所有權驗證)
## 關鍵資料模型
- `users`role enummember / provider / admin),`is_active`
- `provider_profiles`:業者資料(business_name、certifications、dive_sites 等)
- `member_profiles`:會員資料(birthday、gender、emergency_contact 等)
- `diving_offers`:課程(title、location、spot、price、region、tag、badges JSON、provider_id nullable FK
- `subscriptions` / `plans`:訂閱方案(尚未實作 API)
## 前端慣例
- Member 認證:`src/stores/auth.js`localStorage key `token` / `user`
- Coach 認證:`src/stores/coachAuth.js`localStorage key `coach_token` / `coach_user`
- Member Axios`src/api/axios.js`
- Coach Axios`src/api/coachAxios.js`
- Coach 頁面包在 `src/layouts/CoachLayout.vue`(含 CoachNavBar
- 所有 `/coach/*` protected 路由:`meta: { requiresCoach: true }`
## API 回應格式
成功:`{ status: true, message?: "...", data: {...} 或 [...] }`
失敗:`{ status: false, message: "...", errors?: {...} }`
## 尚未實作
- Admin Panel
- 預約/訂單系統
- 金流整合
- 課程圖片上傳
- 教練審核流程
rules:
tasks:
- 後端任務標記 [後端],前端任務標記 [前端],整合測試標記 [整合測試]
- 每個 task 描述應包含具體的檔案路徑或方法名稱
- 手動驗證類 task 放在最後一個 group
+89
View File
@@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: provider_id 所有權不變式
對單一課程操作端點(show / update / destroy),系統 MUST 依序執行:先以 id 查找課程(不存在回 404),再比對 provider_id(不符回 403)。兩步驟不可合併為單一 WHERE 查詢。`store()` MUST 強制將 `provider_id` 設為 `auth()->id()`,忽略 request body 傳入值。
#### Scenario: 課程不存在回傳 404
- **WHEN** 指定 id 的課程不存在於資料庫
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
#### Scenario: 課程存在但非本人回傳 403
- **WHEN** 課程存在(id 有效)但 `offer.provider_id !== auth()->id()`
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限…" }`
#### Scenario: store 強制設定 provider_id
- **WHEN** 教練送出新增課程請求,body 中包含任意 provider_id 值
- **THEN** 系統忽略該值,`offer.provider_id` 固定為 `auth()->id()`
---
### Requirement: 教練課程列表
後端 SHALL 提供 `GET /api/provider/offers`(需 Bearer tokenrole=provider),回傳當前登入教練自己建立的課程,支援分頁。
#### Scenario: 取得自己的課程列表
- **WHEN** 已登入教練送出 GET 請求
- **THEN** 回傳 HTTP 200,只包含 `provider_id = auth()->id()` 的課程,含分頁 meta
#### Scenario: 無課程時回傳空陣列
- **WHEN** 教練尚未建立任何課程
- **THEN** 回傳 HTTP 200`{ status: true, data: [], meta: { total: 0, ... } }`
---
### Requirement: 教練課程詳情
後端 SHALL 提供 `GET /api/provider/offers/{id}`(需 Bearer tokenrole=provider),回傳單一課程完整資料,只允許查看自己建立的課程。
#### Scenario: 取得自己的課程詳情
- **WHEN** 已登入教練送出 `GET /api/provider/offers/1`,且該課程 `provider_id = auth()->id()`
- **THEN** 回傳 HTTP 200`{ status: true, data: { ...offer } }`
#### Scenario: 查看他人課程
- **WHEN** 課程存在但 `provider_id !== auth()->id()`
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限查看此課程" }`
#### Scenario: 課程不存在
- **WHEN** 指定 id 的課程不存在
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
---
### Requirement: 教練新增課程
後端 SHALL 提供 `POST /api/provider/offers`(需 Bearer token),建立新課程並自動設定 `provider_id` 為當前登入教練。
#### Scenario: 新增課程成功
- **WHEN** 教練送出包含 title / location / spot / price / region 的合法資料
- **THEN** 回傳 HTTP 201`{ status: true, data: { ...offer, provider_id: <coach_id> } }`
#### Scenario: 缺少必填欄位
- **WHEN** 教練送出缺少 title 或 price 的資料
- **THEN** 回傳 HTTP 422`{ status: false, message: "...", errors: { field: [...] } }`
---
### Requirement: 教練更新課程
後端 SHALL 提供 `PUT /api/provider/offers/{id}`(需 Bearer token),更新指定課程,只允許修改自己建立的課程。
#### Scenario: 更新自己的課程
- **WHEN** 教練送出合法更新資料且 offer.provider_id === auth()->id()
- **THEN** 回傳 HTTP 200`{ status: true, data: { ...updated_offer } }`
#### Scenario: 嘗試更新他人課程
- **WHEN** offer.provider_id !== auth()->id()
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限修改此課程" }`
#### Scenario: 課程不存在
- **WHEN** 指定 id 的課程不存在
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
---
### Requirement: 教練刪除課程
後端 SHALL 提供 `DELETE /api/provider/offers/{id}`(需 Bearer token),刪除指定課程,只允許刪除自己建立的課程。
#### Scenario: 刪除自己的課程
- **WHEN** offer.provider_id === auth()->id()
- **THEN** 回傳 HTTP 200`{ status: true, message: "課程已刪除" }`,資料庫記錄移除
#### Scenario: 嘗試刪除他人課程
- **WHEN** offer.provider_id !== auth()->id()
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限刪除此課程" }`
+102
View File
@@ -0,0 +1,102 @@
## ADDED Requirements
### Requirement: 教練註冊頁
前端 SHALL 提供 `/coach/register` 頁面,供教練填寫帳號資訊與業者資料後申請帳號,成功後導向 `/coach/login`
#### Scenario: 註冊成功
- **WHEN** 教練填入必填欄位(name / email / password / password_confirmation)並送出
- **THEN** 呼叫 `POST /api/provider/register`,成功後導向 `/coach/login?registered=1`,顯示「註冊成功,請登入」提示
#### Scenario: Email 重複
- **WHEN** 送出已存在的 email
- **THEN** 頁面顯示後端回傳的錯誤訊息,不跳轉
#### Scenario: 密碼不一致
- **WHEN** password 與 password_confirmation 不一致
- **THEN** 後端回傳 422,頁面顯示欄位錯誤提示
#### Scenario: business_name 為選填
- **WHEN** 教練不填寫工作室名稱直接送出
- **THEN** 正常完成註冊,business_name 存為 null
---
### Requirement: 教練登入頁
前端 SHALL 提供 `/coach/login` 頁面,供教練以 email/password 登入,成功後導向 `/coach/dashboard`
#### Scenario: 登入成功
- **WHEN** 教練填入正確帳密並送出
- **THEN** 呼叫 `POST /api/provider/login`token 存入 coachAuth storelocalStorage key: coach_token),導向 `/coach/dashboard`
#### Scenario: 登入失敗
- **WHEN** 帳密錯誤
- **THEN** 頁面顯示錯誤訊息,不跳轉
#### Scenario: 已登入教練訪問登入頁
- **WHEN** coachAuth.isLoggedIn 為 true 時訪問 `/coach/login`
- **THEN** 自動導向 `/coach/dashboard`
---
### Requirement: 課程 Dashboard
前端 SHALL 提供 `/coach/dashboard` 頁面(需教練登入),顯示自己的課程列表,並提供新增、編輯、刪除操作入口。
#### Scenario: 載入課程列表
- **WHEN** 已登入教練訪問 Dashboard
- **THEN** 呼叫 `GET /api/provider/offers`,以表格或卡片渲染課程(標題、地點、價格、狀態)
#### Scenario: 無課程時顯示空狀態
- **WHEN** 教練尚無課程
- **THEN** 顯示「尚無課程,立即新增第一堂課」提示與新增按鈕
#### Scenario: 刪除課程確認
- **WHEN** 教練點擊刪除按鈕
- **THEN** 顯示確認提示,確認後呼叫 `DELETE /api/provider/offers/{id}`,成功後更新列表
---
### Requirement: 新增課程表單
前端 SHALL 提供 `/coach/offers/new` 頁面,教練填寫課程資訊後送出新增。
#### Scenario: 新增課程成功
- **WHEN** 教練填入所有必填欄位並送出
- **THEN** 呼叫 `POST /api/provider/offers`,成功後導向 `/coach/dashboard` 並顯示成功提示
#### Scenario: 表單驗證失敗
- **WHEN** 必填欄位(title / location / price)為空
- **THEN** 前端顯示欄位錯誤提示,不送出 API
---
### Requirement: 編輯課程表單
前端 SHALL 提供 `/coach/offers/:id/edit` 頁面,預填現有課程資料供教練修改。
#### Scenario: 載入課程資料並編輯
- **WHEN** 教練訪問編輯頁
- **THEN** 從 Dashboard 傳入或呼叫 API 取得課程資料,預填表單,送出後呼叫 `PUT /api/provider/offers/{id}`,成功後返回 Dashboard
#### Scenario: 無權限編輯
- **WHEN** API 回傳 403
- **THEN** 頁面顯示「無權限修改此課程」並返回 Dashboard
---
### Requirement: 教練個人資料頁
前端 SHALL 提供 `/coach/profile` 頁面(需教練登入),顯示並允許更新教練基本資訊與專業資料。
#### Scenario: 讀取並更新資料
- **WHEN** 教練訪問個人資料頁
- **THEN** 呼叫 `GET /api/provider/profile`,顯示 name / email / bio / expertise / certification,儲存時呼叫 `PUT /api/provider/profile`
---
### Requirement: Coach 路由守衛
前端 SHALL 對所有 `/coach/*` 路由(login 除外)加上 navigation guard,未登入時導向 `/coach/login`
#### Scenario: 未登入訪問 Dashboard
- **WHEN** 未登入使用者直接訪問 `/coach/dashboard`
- **THEN** 自動導向 `/coach/login`
#### Scenario: 登出
- **WHEN** 教練點擊登出
- **THEN** 呼叫 `POST /api/provider/logout`,清除 coach_token / coach_user,導向 `/coach/login`
+2 -2
View File
@@ -1,11 +1,11 @@
## ADDED Requirements
### Requirement: 課程列表 API
後端 SHALL 提供公開的 `GET /api/diving-offers` endpoint,回傳分頁的潛水課程列表,支援關鍵字搜尋與篩選,無需認證即可存取。
後端 SHALL 提供公開的 `GET /api/diving-offers` endpoint,回傳分頁的潛水課程列表,支援關鍵字搜尋與篩選,無需認證即可存取。response 中每筆課程包含 `provider_id` 欄位(可為 null)。
#### Scenario: 取得全部課程列表
- **WHEN** 客戶端發送 `GET /api/diving-offers` 且不帶任何參數
- **THEN** 回傳 HTTP 200body 包含 `{ data: [...], meta: { total, per_page, current_page } }`,預設每頁 12 筆
- **THEN** 回傳 HTTP 200body 包含 `{ data: [...], meta: { total, per_page, current_page } }`,預設每頁 12 筆,每筆資料含 `provider_id`
#### Scenario: 依關鍵字搜尋課程
- **WHEN** 客戶端發送 `GET /api/diving-offers?q=墾丁`
+56
View File
@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: 教練帳號登入
後端 SHALL 提供 `POST /api/provider/login`,驗證 email/password 並回傳 Sanctum Bearer token,僅限 role=provider 帳號。
#### Scenario: 正確帳密登入成功
- **WHEN** 教練送出正確的 email 與 password
- **THEN** 回傳 HTTP 200,包含 `{ status: true, data: { user, token, token_type: "Bearer" } }`
#### Scenario: 錯誤帳密登入失敗
- **WHEN** 教練送出錯誤的 email 或 password
- **THEN** 回傳 HTTP 401`{ status: false, message: "帳號或密碼錯誤" }`
#### Scenario: 會員帳號無法用教練登入
- **WHEN** role=member 的帳號嘗試呼叫 `/api/provider/login`
- **THEN** 回傳 HTTP 403`{ status: false, message: "此帳號非教練角色" }`
---
### Requirement: 教練帳號註冊
後端 SHALL 提供 `POST /api/provider/register`,建立 role=provider 的 User 與對應 ProviderProfile。
#### Scenario: 新帳號註冊成功
- **WHEN** 送出有效的 name / email / password / password_confirmation
- **THEN** 回傳 HTTP 201`{ status: true, data: { user } }`
#### Scenario: Email 重複
- **WHEN** 送出已存在的 email
- **THEN** 回傳 HTTP 422`{ status: false, message: "此 Email 已被使用" }`
---
### Requirement: 教練登出
後端 SHALL 提供 `POST /api/provider/logout`(需 Bearer token),撤銷當前 token。
#### Scenario: 登出成功
- **WHEN** 已登入教練送出登出請求
- **THEN** 回傳 HTTP 200`{ status: true, message: "已登出" }`token 失效
---
### Requirement: 教練個人資料讀取
後端 SHALL 提供 `GET /api/provider/profile`(需 Bearer token),回傳教練基本資訊與 ProviderProfile。
#### Scenario: 取得個人資料
- **WHEN** 已登入教練送出 GET 請求
- **THEN** 回傳 HTTP 200,包含 `{ id, name, email, role, profile: { bio, expertise, certification, avatar } }`
---
### Requirement: 教練個人資料更新
後端 SHALL 提供 `PUT /api/provider/profile`(需 Bearer token),更新教練基本資訊與 ProviderProfile。
#### Scenario: 更新成功
- **WHEN** 教練送出合法的更新資料
- **THEN** 回傳 HTTP 200`{ status: true, message: "資料已更新", data: { ...profile } }`