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:
@@ -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 token,role=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 token,role=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 store(localStorage 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 200,body 包含 `{ 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 } }`
|
||||
Reference in New Issue
Block a user