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,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