Files
CFDivePlatform/openspec/changes/archive/2026-05-10-coach-portal/design.md
T
a620906209 da48a3652d 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>
2026-05-10 03:34:14 +08:00

6.9 KiB
Raw Blame History

Context

後端 AuthController 已有完整的 Provider Auth 方法實作(login / logout / profile / register / update),經審查後大部分可直接沿用,僅需對 registerProviderupdateProviderProfile 做小幅欄位調整以對齊 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.jsmember)完全分開。localStorage key 用 coach_token / coach_user 區分。

理由:同一個瀏覽器可能同時開著會員頁和教練後台(不同 tab)。共用 store 會互相污染登入狀態。


D3Provider Auth / Profile 沿用現有 AuthController,只做必要調整

決定:沿用現有 AuthControllerregisterProviderloginProviderlogoutProviderproviderProfileupdateProviderProfile 方法,僅針對教練情境補充欄位與調整驗證規則。

理由:現有路由與主要邏輯已存在,本次以最小修改滿足 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_idnullable 外鍵,現有測試資料不受影響。

理由:現有 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

  • ProviderProfileCoachProfile 兩個 model 並存,目前 provider login 應該用哪個 profile?→ 決定統一使用 ProviderProfileCoachProfile 暫時忽略(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