後端: - 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>
6.9 KiB
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
D1:Coach 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 會互相污染登入狀態。
D3:Provider 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)依序執行兩步驟,不可合併:
DivingOffer::find($id)→ null 時回傳 404offer->provider_id !== auth()->id()→ 回傳 403
此設計會洩漏資源存在性,為刻意取捨:diving_offers 使用自增整數 ID,資源存在性本可枚舉,安全遮蔽收益有限;而對教練而言,明確區分「課程不存在」與「無權限」有實際操作價值。
store() 補充規則:強制將 provider_id 設為 auth()->id(),忽略 request body 中任何傳入值。
D5:diving_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 token,role=provider)
Response 200: { "status": true, "data": [...offers], "meta": { total, per_page, current_page, last_page } }
GET /api/provider/offers/{id}(需 Bearer token,role=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
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