feat:實作通知系統 — 站內通知、Email 通知、Polling 機制

後端
- 新增 6 個 Notification class(預約建立/確認/拒絕/取消/完成、收到評價),database + mail 雙 channel
- 新增 NotificationController(list / unread-count / markRead / markAllRead / destroy)
- 整合通知觸發至 MemberBookingController、ProviderBookingController、CompleteFinishedBookings、ReviewController
- 新增 notifications / jobs / failed_jobs migration
- Docker Compose 加入 queue-worker、mailpit service
- DivingOffer 補上 provider() 關聯

前端
- 新增 notificationStore(Polling 30s/60s 自適應 + Page Visibility API)
- 新增 NotificationBell(未讀 Badge)、NotificationDrawer(側邊通知中心)
- main.js:auth store init 前置於 router.use(),修正 beforeEach guard 時序問題
- notificationAxios:依路徑動態選擇 member/coach token
- NotificationDrawer:改用 new URL().pathname 提取 action_url 路徑

OpenSpec
- 歸檔 notification-system change
- 同步 notification-core / notification-email / notification-triggers specs 至主規格
- 更新 booking-lifecycle / review-lifecycle spec(補充通知觸發 requirement)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:26:14 +08:00
parent 4baa4cb52b
commit 03f8caf3e9
46 changed files with 2709 additions and 21 deletions
@@ -0,0 +1,286 @@
## Context
平台目前已有完整的預約七狀態機與評價系統,但所有狀態轉換都是「靜默」執行,使用者只能回到頁面主動查看。本設計在不引入複雜即時通訊基礎設施的前提下,以 Laravel 內建 Notification + 前端 Polling 實作通知系統。
## Goals / Non-Goals
**Goals:**
- 站內通知(In-App):Bell Icon 未讀計數 + 通知中心 Drawer,覆蓋 Member / Provider 兩個角色
- Email 通知:以 Laravel Queue + Mailable 非同步寄出,本地用 Mailpit 測試
- 觸發整合:BookingService、ReviewService、Admin 審核流程各轉換點
- 標記已讀(單一 / 全部)、刪除通知
**Non-Goals:**
- WebSocket / Push Notification(瀏覽器推播)
- SMS 通知
- 通知偏好設定(使用者選擇開關)
- Admin 角色通知(本次範圍僅 Member + Provider
## Decisions
### 1. 使用 Laravel 內建 Notification 系統
**選擇**`Notifiable` trait + 各 Notification class 各自控制 `via()`
**理由**
- `database` channel 自動建立 `notifications` 資料表,schema 標準化
- `mail` channel 直接整合 Mailable + Queue
- **每個 class 獨立控制 `via()`**,避免「評價通知意外寄出 Email」等誤觸;新增類型只需新增一個 class
**各 Notification class 的 `via()` 設定**
| Class | `via()` | 說明 |
|-------|---------|------|
| `BookingCreatedNotification` | `['database', 'mail']` | 有新預約,Provider 需即時知道 |
| `BookingConfirmedNotification` | `['database', 'mail']` | 確認是 Member 最期待的通知 |
| `BookingRejectedNotification` | `['database', 'mail']` | 需要 Email 確保 Member 收到 |
| `BookingCancelledNotification` | `['database', 'mail']` | 取消對雙方均重要 |
| `BookingCompletedNotification` | `['database', 'mail']` | Email CTA 引導評價 |
| `ReviewReceivedNotification` | `['database']` | 告知性通知,不值得寄 Email 打擾 |
**放棄的方案**:所有 class 共用一個 `via()` 設定 — 會導致評價通知也寄 Email,過度打擾 Provider。
---
### 2. 前端即時性:Polling(非 WebSocket
**選擇**:前端登入後 Polling `GET /api/notifications/unread-count`,搭配 Page Visibility API 節省請求
**理由**
- 平台目前流量低,WebSocket 基礎設施(Pusher / Laravel Echo Server)成本不對等
- SSE 需要長連線,Docker 環境 Nginx timeout 需另外調整
- 30 秒延遲對「預約確認」類通知可接受
**降頻邏輯(細化)**
```
登入後 → 立即執行第一次 fetch(不等待 30s)
有未讀(count > 0 → interval = 30s
無未讀(count = 0 → interval = 60s
頁面隱藏(visibilitychange = hidden → 暫停 interval
頁面重新顯示(visibilitychange = visible → 立即 fetch 一次,然後重啟 interval
登出 → clearInterval + removeEventListener
```
**實作方式**`startPolling()` 建立 `setInterval`,每次 fetch 後比較新舊 count:若 count 從 > 0 變為 0(或反之),`clearInterval` 並以新 interval 重啟。Page Visibility 由 `document.addEventListener('visibilitychange', handler)` 控制。
**升級路徑**:未來可替換為 Laravel Reverb(官方 WebSocket server),前端改用 Echostore 的 `unreadCount`/`notifications` state 介面不變。
---
### 3. Queue Driverdatabase(現有 MySQL
**選擇**`QUEUE_CONNECTION=database`,使用現有 MySQL
**理由**
- 專案已有 MySQL,不需額外部署 Redis
- Email 通知量少(非高頻),database queue 足夠
- 啟動命令加入 `php artisan queue:work --daemon` 或在 Docker CMD 中加入
**升級路徑**`QUEUE_CONNECTION=redis`,只需改 .env,不動業務邏輯。
---
### 4. 通知類型設計(data JSON 欄位統一格式)
每個 Notification class 的 `toArray()` 回傳統一結構:
```json
{
"type": "booking_confirmed",
"title": "預約已確認",
"body": "你的《自由潛水入門》課程預約已由教練確認",
"action_url": "http://localhost:5173/my-bookings",
"related_id": 123,
"related_type": "booking"
}
```
**action_url 格式決定(修正)**`action_url` 儲存完整 URL(含 `FRONTEND_URL` prefix),前端以 `new URL(action_url).pathname` 提取路徑再傳入 `router.push()`。**不含個別 booking ID**,原因:前端路由只有 `/my-bookings`(列表),無 `/my-bookings/:id` 詳情頁,帶 ID 會導致 404。
前端根據 `type` 決定 icon 顏色與動作連結。
---
### 5. 通知觸發架構:直接插入現有 Controller(不建立 Service 層)
**現況確認**:專案**無 BookingService / ReviewService**。業務邏輯分散於:
- `MemberBookingController`(建立預約、Member 取消)
- `ProviderBookingController`(確認、拒絕、Provider 取消、手動完成)
- `CompleteFinishedBookings` Command(排程自動完成)
- `ReviewController::store()`(評價建立)
**選擇**:直接在上述 Controller / Command 的對應方法中,於主業務 DB 操作後插入 `$user->notify(...)`,以 try/catch 包裹。
**理由**
- 本次任務不需要 Service 抽象,建立 Service 只是為了通知而引入不必要的重構
- Inline notify 可讀性佳,出問題容易定位到發送點
- Observer 或 Event/Listener 會讓觸發點不直觀(多一層間接)
**DivingOffer `provider()` 關聯需新增**
`DivingOffer``provider_id` FK 但**無 Eloquent 關聯方法**。實作前需在 `DivingOffer` model 補上:
```php
public function provider(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class, 'provider_id');
}
```
之後 ReviewController 及各 BookingController 統一使用 `$offer->provider`(而非 `$offer->user`)。
**ReviewController 取得 Provider 的正確方式**
```php
// ReviewController::store() 中
$offer = DivingOffer::with('provider')->findOrFail($offerId);
$provider = $offer->provider;
try {
$provider->notify(new ReviewReceivedNotification($review));
} catch (\Throwable $e) {
\Log::error('ReviewNotification failed: ' . $e->getMessage());
}
```
**BookingCancelledNotification 依 `$cancelledBy` 區分文案**
| `$cancelledBy` | 通知對象 | title | body |
|----------------|---------|-------|------|
| `'member'` | Provider | 學員取消了預約 | 學員已取消《課程名稱》的預約(時段:日期)|
| `'provider'` | Member | 教練取消了你的預約 | 教練已取消你的《課程名稱》預約(時段:日期),如有疑問請聯繫教練 |
```php
// 使用範例
// MemberBookingController::cancel() 中
$provider = $booking->schedule->divingOffer->provider;
try {
$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'));
} catch (\Throwable $e) { \Log::error(...); }
// ProviderBookingController::cancel() 中
$member = $booking->member;
try {
$member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider'));
} catch (\Throwable $e) { \Log::error(...); }
```
---
### 6. Email 模板:Laravel Markdown Mailable
使用 `php artisan make:mail` + `markdown` 參數,產生 `resources/views/emails/notifications/` 下的 Blade 模板。本地使用 MailpitDocker service `mailpit`port 1025/8025)攔截信件,不真實發送。
### 7-前置. Email action_url — FRONTEND_URL 設定
**現況**`.env` 已有 `FRONTEND_URL=http://localhost:5173`,但 `config/app.php` **未註冊**此值,無法透過 `config()` 讀取。
**決定**:在 `config/app.php` 加入:
```php
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
```
Notification class 中使用:
```php
'action_url' => config('app.frontend_url') . '/my-bookings/' . $this->booking->id,
```
`.env.example` 同步補上 `FRONTEND_URL=http://localhost:5173`
**各場景 action_url 對應**
| 通知 | action_url |
|------|-----------|
| BookingCreated(→ Provider | `{FRONTEND_URL}/coach/bookings` |
| BookingConfirmed / Rejected / Cancelled / Completed(→ Member | `{FRONTEND_URL}/my-bookings`(無 booking ID,前端路由無 `/my-bookings/:id` |
| ReviewReceived(→ Provider | `{FRONTEND_URL}/coach/reviews` |
### 7. API 路由完整定義
所有路由掛在 `auth:sanctum` middleware 下,Member token 與 Provider token 均適用(`Notifiable` 基於 `User` model,兩者共用同一張 `notifications` 資料表)。
| Method | Path | Controller@method | 說明 |
|--------|------|-------------------|------|
| `GET` | `/api/notifications` | `NotificationController@index` | 列表(分頁 20,DESC),含 `unread_count` |
| `GET` | `/api/notifications/unread-count` | `NotificationController@unreadCount` | Polling 專用,回傳 `{ count }` |
| `PATCH` | `/api/notifications/{id}/read` | `NotificationController@markRead` | 單一標記已讀 |
| `PATCH` | `/api/notifications/read-all` | `NotificationController@markAllRead` | 全部標記已讀 |
| `DELETE` | `/api/notifications/{id}` | `NotificationController@destroy` | 刪除單筆 |
**路由順序注意**`/read-all` 必須定義在 `/{id}/read` **之前**,避免 Laravel 把 `read-all` 當成 `{id}` 綁定。
### 8. 觸發場景完整列表
| # | 事件 | 觸發位置 | 通知對象 | Channels |
|---|------|---------|---------|---------|
| 1 | 預約建立(`pending` | `BookingService::create()` | Provider | DB + Mail |
| 2 | 預約確認(`confirmed` | `BookingService::confirm()` | Member | DB + Mail |
| 3 | 預約拒絕(`rejected` | `BookingService::reject()` | Member | DB + Mail |
| 4 | 預約取消(`member_cancelled` | `BookingService::cancelByMember()` | Provider | DB + Mail |
| 5 | 預約取消(`provider_cancelled` | `BookingService::cancelByProvider()` | Member | DB + Mail |
| 6 | 預約完成(`completed` | `BookingService::complete()` | Member | DB + Mail |
| 7 | 收到評價 | `ReviewService::create()` | Provider | DB only |
> 場景共 7 個(含取消分 Member/Provider 兩方),對應 6 個 Notification class`BookingCancelledNotification` 透過 `$cancelledBy` 參數區分文案)。
## Risks / Trade-offs
| 風險 | 緩解策略 |
|------|----------|
| `CompleteFinishedBookings` N+1 查詢 | 現行用 bulk `->update()` 無法逐筆 notify**需改為 `->with(['member', 'schedule.divingOffer.provider'])->get()` + loop**notify 仍在 loop 內,但 eager load 確保無 N+1 |
| Polling 造成 API 請求量上升 | 只在使用者登入且頁面 visible 時輪詢;未讀數 0 時降頻至 60s |
| Queue Worker 未啟動導致 Email 卡住 | Docker Compose 加入 `queue-worker` servicesupervisor 管理 |
| `notifications` 資料表無限增長 | 建議每月清理 90 天前已讀通知(`php artisan notifications:prune`Laravel 內建) |
| Email 寄信失敗無重試上限 | Queue job 設定 `$tries = 3`,失敗寫入 `failed_jobs` |
## Migration Plan
1. 執行 `php artisan notifications:table` + `php artisan queue:table` → migrate
2. 建立 Notification classes6 種觸發場景)
3. 整合 BookingService / ReviewService / Admin controller
4. 建立 NotificationController + API routes
5. Docker Compose 加入 queue-worker service
6. 前端:Notification Pinia store → Bell Icon 元件 → Drawer 元件 → 整合至兩個 NavBar
### 9. 前端 Store 初始化時序
**問題**Vue Router 的 `beforeEach` guard 在 `App.vue``onMounted` 之前執行。原本設計把三個 auth store 的 `init()`(讀 localStorage → 設定 `token.value`)放在 `onMounted`,導致 guard 跑時 `isLoggedIn` 永遠是 false,所有 protected route 均被踢回 login。
**決定**:在 `main.js` 中,`app.use(pinia)` 安裝後、`app.use(router)` 安裝前,同步呼叫三個 store 的 `init()`
```js
app.use(pinia)
useAuthStore().init()
useCoachAuthStore().init()
useAdminAuthStore().init()
app.use(router)
app.mount('#app')
```
**影響**`App.vue` 不再需要 `onMounted`,三個 auth store import 從 `App.vue` 移至 `main.js`
---
### 10. 通知 API Token 選擇邏輯
**問題**Member 與 Coach 使用同一個 `notificationAxios` 實例,interceptor 原本固定以 `token || coach_token` 順序取用。若瀏覽器同時持有兩種 token(測試情境),永遠使用 member token,導致 coach 通知 API 回傳 member 的空資料。
**決定**:依當前頁面路徑動態選 token:
```js
const isCoachPage = window.location.pathname.startsWith('/coach')
const token = isCoachPage
? (localStorage.getItem('coach_token') || localStorage.getItem('token'))
: (localStorage.getItem('token') || localStorage.getItem('coach_token'))
```
**理由**:路徑是判斷「使用者當前身份上下文」最直接的信號,無需引入 Pinia store 至 axios interceptor(避免循環依賴)。
---
## Open Questions
> 所有問題已關閉,實作可直接開始。
| 問題 | 決定 |
|------|------|
| Mailpit 是否已加入 Docker Compose | **否,需在 task 1.6 補上**`docker-compose.yml` 新增 `mailpit` service`axllent/mailpit`),`.env` 設定 `MAIL_HOST=mailpit MAIL_PORT=1025`。 |
| Admin 角色通知未來是否需要? | **本次排除**。Admin 主要操作在後台(有即時 UI feedback),不在此 change 範圍,未來若需要另開 change。 |
| 通知是否需要「點擊後自動標記已讀」行為? | **是**。點擊 Drawer 中任一通知項目時,前端呼叫 `PATCH /api/notifications/{id}/read`,然後才執行 `router.push(action_url)`(不需等待 API responseOptimistic update)。 |