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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-17
|
||||
@@ -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),前端改用 Echo,store 的 `unreadCount`/`notifications` state 介面不變。
|
||||
|
||||
---
|
||||
|
||||
### 3. Queue Driver:database(現有 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 模板。本地使用 Mailpit(Docker 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` service,supervisor 管理 |
|
||||
| `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 classes(6 種觸發場景)
|
||||
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 response,Optimistic update)。 |
|
||||
@@ -0,0 +1,36 @@
|
||||
## Why
|
||||
|
||||
預約確認、取消、評價等關鍵事件目前完全沒有通知機制,使用者只能主動回頁面查看,造成重要訊息遺漏。實作通知系統可閉合「事件發生→使用者知情」這段空白,提升平台使用黏著度。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增**站內通知(In-App Notification)**:所有角色(Member / Provider / Admin)可在導覽列看到未讀數量,點開通知中心查看全部通知
|
||||
- 新增**Email 通知**:重要事件以信件寄送,使用 Laravel Queued Mailable + Markdown 模板
|
||||
- 新增**通知觸發點**整合至現有業務邏輯(預約、評價、教練審核):
|
||||
- 預約建立 → 通知 Provider
|
||||
- 預約確認/拒絕 → 通知 Member
|
||||
- 預約取消(任一方) → 通知對方
|
||||
- 預約完成 → 通知 Member(可評價)
|
||||
- Member 送出評價 → 通知 Provider
|
||||
- Admin 審核/拒絕教練申請 → 通知 Provider
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `notification-core`: 通知資料模型、API(取得列表、標記已讀、刪除)、Vue 站內通知元件(Bell Icon + 通知中心 Drawer)
|
||||
- `notification-email`: Laravel Mail 設定、Markdown 模板、Queue 投遞機制
|
||||
- `notification-triggers`: 在 BookingService / ReviewService / Admin 審核流程中插入通知觸發邏輯
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `booking-lifecycle`: 預約七狀態機各轉換點需加上通知觸發
|
||||
- `review-lifecycle`: 評價建立後需觸發 Provider 通知
|
||||
|
||||
## Impact
|
||||
|
||||
- **新增資料表**:`notifications`(Laravel 內建 `database` notification channel schema)
|
||||
- **新增 API**:`GET /api/notifications`、`PATCH /api/notifications/{id}/read`、`PATCH /api/notifications/read-all`、`DELETE /api/notifications/{id}`
|
||||
- **後端依賴**:Laravel Notification + Queue(database driver,可升級為 Redis)、Laravel Mail(SMTP/Mailpit 本地測試)
|
||||
- **前端依賴**:Pinia store for notifications、Polling 或 SSE 取得即時未讀數
|
||||
- **影響範圍**:BookingService、ReviewService、Admin 教練審核 controller
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 預約狀態轉換觸發通知
|
||||
|
||||
預約七狀態機(`pending` / `confirmed` / `completed` / `rejected` / `expired` / `member_cancelled` / `provider_cancelled`)的每個轉換點,系統 SHALL 在狀態成功更新後觸發對應通知(詳見 `notification-triggers` spec)。通知觸發 MUST 在主業務 transaction commit 之後執行,且以 try/catch 包裹,不影響主業務結果。
|
||||
|
||||
#### Scenario: 狀態轉換後通知觸發
|
||||
|
||||
- **WHEN** `BookingService` 中任一狀態轉換方法成功執行
|
||||
- **THEN** 對應的 Notification class 被觸發,不論通知是否成功主業務均正常回傳
|
||||
|
||||
#### Scenario: 通知失敗不影響主業務
|
||||
|
||||
- **WHEN** notify 呼叫拋出例外
|
||||
- **THEN** 預約狀態已正確儲存,HTTP response 成功回傳,錯誤記錄至 Laravel log
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 通知資料模型
|
||||
|
||||
系統 SHALL 使用 Laravel 內建 `notifications` 資料表儲存站內通知,每筆通知包含:`id`(UUID)、`type`(Notification class 名稱)、`notifiable_type` / `notifiable_id`(多型關聯至 User)、`data`(JSON,含 type / title / body / action_url / related_id / related_type)、`read_at`(nullable)、`created_at` / `updated_at`。
|
||||
|
||||
#### Scenario: 通知建立
|
||||
|
||||
- **WHEN** 業務邏輯觸發 `$user->notify(new XxxNotification(...))`
|
||||
- **THEN** `notifications` 資料表新增一筆記錄,`read_at` 為 null
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 取得通知列表 API
|
||||
|
||||
`GET /api/notifications` SHALL 回傳當前登入使用者的通知列表(含已讀/未讀),分頁 20 筆,依 `created_at` DESC 排序。
|
||||
|
||||
Response data 格式:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"type": "booking_confirmed",
|
||||
"title": "預約已確認",
|
||||
"body": "...",
|
||||
"action_url": "http://localhost:5173/my-bookings",
|
||||
"read_at": null,
|
||||
"created_at": "2026-05-17T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"unread_count": 3,
|
||||
"meta": { "current_page": 1, "last_page": 2 }
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: 已登入使用者取得通知
|
||||
|
||||
- **WHEN** 已登入 Member 呼叫 `GET /api/notifications`
|
||||
- **THEN** 回傳 `status: true`,`data` 陣列包含該使用者的通知,最新在前
|
||||
|
||||
#### Scenario: 未登入拒絕存取
|
||||
|
||||
- **WHEN** 未帶 Token 呼叫 `GET /api/notifications`
|
||||
- **THEN** 回傳 HTTP 401
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 取得未讀數量 API
|
||||
|
||||
`GET /api/notifications/unread-count` SHALL 回傳當前使用者未讀通知數量,用於 Polling。
|
||||
|
||||
Response:`{ "status": true, "data": { "count": 3 } }`
|
||||
|
||||
#### Scenario: 有未讀通知
|
||||
|
||||
- **WHEN** 使用者有 3 筆 `read_at = null` 的通知時呼叫
|
||||
- **THEN** 回傳 `count: 3`
|
||||
|
||||
#### Scenario: 無未讀通知
|
||||
|
||||
- **WHEN** 所有通知 `read_at` 均不為 null
|
||||
- **THEN** 回傳 `count: 0`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 標記單一通知為已讀
|
||||
|
||||
`PATCH /api/notifications/{id}/read` SHALL 將指定通知的 `read_at` 設為當前時間。
|
||||
|
||||
#### Scenario: 標記成功
|
||||
|
||||
- **WHEN** 已登入使用者對自己的通知呼叫此 API
|
||||
- **THEN** 回傳 `status: true`,`read_at` 不再為 null
|
||||
|
||||
#### Scenario: 非本人通知拒絕
|
||||
|
||||
- **WHEN** 使用者嘗試標記他人通知
|
||||
- **THEN** 回傳 HTTP 403
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 標記全部通知為已讀
|
||||
|
||||
`PATCH /api/notifications/read-all` SHALL 將當前使用者所有未讀通知一次標記為已讀。
|
||||
|
||||
#### Scenario: 批次標記
|
||||
|
||||
- **WHEN** 使用者有 5 筆未讀,呼叫此 API
|
||||
- **THEN** 所有 5 筆 `read_at` 更新,回傳 `status: true`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 刪除通知
|
||||
|
||||
`DELETE /api/notifications/{id}` SHALL 永久刪除指定通知。
|
||||
|
||||
#### Scenario: 刪除成功
|
||||
|
||||
- **WHEN** 已登入使用者刪除自己的通知
|
||||
- **THEN** 該通知從資料庫移除,回傳 HTTP 204
|
||||
|
||||
#### Scenario: 非本人通知拒絕刪除
|
||||
|
||||
- **WHEN** 使用者嘗試刪除他人通知
|
||||
- **THEN** 回傳 HTTP 403
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 前端 Bell Icon 未讀計數
|
||||
|
||||
NavBar(MemberNavBar + CoachNavBar)SHALL 顯示通知鈴鐺圖示,未讀數量 > 0 時顯示紅色 Badge。
|
||||
|
||||
#### Scenario: 有未讀通知
|
||||
|
||||
- **WHEN** 使用者登入後 Pinia store polling 回傳 `count > 0`
|
||||
- **THEN** Bell Icon 顯示紅色數字 Badge
|
||||
|
||||
#### Scenario: 無未讀通知
|
||||
|
||||
- **WHEN** `count === 0`
|
||||
- **THEN** Badge 不顯示(隱藏,不佔位)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 前端通知中心 Drawer
|
||||
|
||||
點擊 Bell Icon SHALL 開啟側邊 Drawer,列出最新 20 筆通知,每筆顯示 title、body(截斷 80 字)、時間(相對時間)、已讀/未讀狀態。
|
||||
|
||||
#### Scenario: 點擊通知項目
|
||||
|
||||
- **WHEN** 使用者點擊通知項目
|
||||
- **THEN** 通知標記為已讀(Optimistic update),並以 `new URL(action_url).pathname` 提取路徑後呼叫 `router.push()`,跳轉至對應頁面
|
||||
|
||||
#### Scenario: 點擊「全部標記已讀」
|
||||
|
||||
- **WHEN** 使用者點擊 Drawer 頂部「全部標為已讀」按鈕
|
||||
- **THEN** 呼叫 `PATCH /api/notifications/read-all`,所有項目變為已讀樣式
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Polling 機制
|
||||
|
||||
前端 Pinia `notificationStore` SHALL 在使用者登入後立即執行第一次 fetch,並依未讀數量動態調整輪詢間隔:未讀 > 0 → 30 秒;未讀 = 0 → 60 秒。間隔切換時 MUST `clearInterval` 後以新間隔重新建立。登出後清除計時器與 Page Visibility 監聽器。
|
||||
|
||||
#### Scenario: 登入後立即 fetch
|
||||
|
||||
- **WHEN** 使用者成功登入(Member 或 Coach)
|
||||
- **THEN** `notificationStore.startPolling()` 立即呼叫一次 `fetchUnreadCount()`,不等待第一個 interval 到期
|
||||
|
||||
#### Scenario: 有未讀時使用 30 秒間隔
|
||||
|
||||
- **WHEN** `fetchUnreadCount()` 回傳 `count > 0`
|
||||
- **THEN** interval 設為 30 秒(若目前為 60 秒則 clearInterval 重啟)
|
||||
|
||||
#### Scenario: 無未讀時降頻至 60 秒
|
||||
|
||||
- **WHEN** `fetchUnreadCount()` 回傳 `count === 0`
|
||||
- **THEN** interval 設為 60 秒(若目前為 30 秒則 clearInterval 重啟)
|
||||
|
||||
#### Scenario: 頁面切換至背景時暫停
|
||||
|
||||
- **WHEN** `document.visibilityState === 'hidden'`(使用者切換 Tab 或最小化視窗)
|
||||
- **THEN** clearInterval 暫停 polling,不發出 API 請求
|
||||
|
||||
#### Scenario: 頁面重新顯示時恢復
|
||||
|
||||
- **WHEN** `document.visibilityState === 'visible'`(使用者回到此 Tab)
|
||||
- **THEN** 立即執行一次 `fetchUnreadCount()`,然後依最新 count 重啟 interval
|
||||
|
||||
#### Scenario: 登出後停止
|
||||
|
||||
- **WHEN** 使用者登出
|
||||
- **THEN** `notificationStore.stopPolling()` 執行 `clearInterval` 並 `removeEventListener('visibilitychange', ...)`,不再發出任何請求
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Laravel Mail 設定
|
||||
|
||||
系統 SHALL 支援透過 SMTP 寄送 Email 通知。本地開發環境使用 Mailpit(Docker service)攔截所有寄出信件,不真實發送。`.env` 設定:`MAIL_MAILER=smtp`、`MAIL_HOST=mailpit`(Docker service name)、`MAIL_PORT=1025`。
|
||||
|
||||
#### Scenario: 本地環境信件攔截
|
||||
|
||||
- **WHEN** 系統觸發 Email 通知
|
||||
- **THEN** 信件出現在 Mailpit Web UI(`http://localhost:8025`),未真實寄出
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Queue Worker 處理 Email 投遞
|
||||
|
||||
Email 通知 SHALL 透過 Laravel Queue(`QUEUE_CONNECTION=database`)非同步投遞,不阻塞 HTTP response。Queue Worker 在 Docker Compose 中以獨立 service 啟動。
|
||||
|
||||
#### Scenario: Email 加入 Queue
|
||||
|
||||
- **WHEN** 業務邏輯觸發 notify,`via()` 包含 `'mail'`
|
||||
- **THEN** Email job 進入 `jobs` 資料表,HTTP response 立即回傳
|
||||
|
||||
#### Scenario: Queue Worker 處理後寄出
|
||||
|
||||
- **WHEN** queue:work 讀取到 Email job
|
||||
- **THEN** Mailable 被實際執行,信件送至 SMTP(本地為 Mailpit)
|
||||
|
||||
#### Scenario: 失敗重試
|
||||
|
||||
- **WHEN** SMTP 連線失敗
|
||||
- **THEN** Job 重試最多 3 次(`$tries = 3`),超過後寫入 `failed_jobs`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Email Markdown 模板
|
||||
|
||||
每種通知場景 SHALL 有對應的 Laravel Markdown Mailable 模板,存放於 `resources/views/emails/notifications/`。模板須包含:平台名稱(CFDivePlatform)、通知標題、正文、行動連結按鈕(CTA)、底部免責聲明。
|
||||
|
||||
涵蓋場景(共 6 種):
|
||||
- `booking-created.blade.php`(給 Provider)
|
||||
- `booking-confirmed.blade.php`(給 Member)
|
||||
- `booking-rejected.blade.php`(給 Member)
|
||||
- `booking-cancelled.blade.php`(給對方)
|
||||
- `booking-completed.blade.php`(給 Member)
|
||||
- `review-received.blade.php`(給 Provider)
|
||||
|
||||
#### Scenario: Email 內容包含行動連結
|
||||
|
||||
- **WHEN** Member 收到「預約已確認」Email
|
||||
- **THEN** 信件包含「查看預約」按鈕,點擊後導向 `{APP_URL}/my-bookings/{id}`
|
||||
|
||||
#### Scenario: Email 主旨語言
|
||||
|
||||
- **WHEN** 系統寄出任何通知 Email
|
||||
- **THEN** 主旨以繁體中文撰寫(例:「你的預約已確認 — CFDivePlatform」)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Email 通知觸發條件與收件人
|
||||
|
||||
| 事件 | 收件人 | 主旨 |
|
||||
|------|--------|------|
|
||||
| 預約建立(pending) | Provider | 你有新的預約申請 |
|
||||
| 預約確認(confirmed) | Member | 你的預約已確認 |
|
||||
| 預約拒絕(rejected) | Member | 你的預約申請未通過 |
|
||||
| 預約取消(任一方) | 對方 | 預約已取消 |
|
||||
| 預約完成(completed) | Member | 預約完成,歡迎留下評價 |
|
||||
| 收到新評價 | Provider | 你收到了一則新評價 |
|
||||
|
||||
#### Scenario: 預約建立後 Provider 收到 Email
|
||||
|
||||
- **WHEN** Member 成功建立預約(status 為 pending)
|
||||
- **THEN** 課程所屬 Provider 在 Queue 處理後收到「你有新的預約申請」Email
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 預約建立觸發通知
|
||||
|
||||
系統 SHALL 在預約成功建立(status = `pending`)時,通知課程所屬 Provider(站內 + Email)。觸發點在 `MemberBookingController::store()` 的 DB transaction commit 之後。
|
||||
|
||||
#### Scenario: Member 建立預約
|
||||
|
||||
- **WHEN** `MemberBookingController::store()` 成功建立預約並回傳 201
|
||||
- **THEN** 取得 `$booking->schedule->divingOffer->provider`(Provider),呼叫 `$provider->notify(new BookingCreatedNotification($booking))`,以 try/catch 包裹
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 預約確認觸發通知
|
||||
|
||||
系統 SHALL 在 Provider 確認預約(status `pending` → `confirmed`)時,通知 Member(站內 + Email)。觸發點在 `ProviderBookingController::confirm()` 的 DB transaction commit 之後。
|
||||
|
||||
#### Scenario: Provider 確認預約
|
||||
|
||||
- **WHEN** `ProviderBookingController::confirm()` 執行,狀態更新為 `confirmed`
|
||||
- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingConfirmedNotification($booking))`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 預約拒絕觸發通知
|
||||
|
||||
系統 SHALL 在 Provider 拒絕預約(status → `rejected`)時,通知 Member(站內 + Email)。觸發點在 `ProviderBookingController::reject()` 的 `$booking->update()` 之後。
|
||||
|
||||
#### Scenario: Provider 拒絕預約
|
||||
|
||||
- **WHEN** `ProviderBookingController::reject()` 執行
|
||||
- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingRejectedNotification($booking))`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BookingCancelledNotification 文案區分
|
||||
|
||||
`BookingCancelledNotification` SHALL 依建構子參數 `cancelledBy: 'member' | 'provider'` 產生不同文案:
|
||||
|
||||
| cancelledBy | 通知對象 | title | body |
|
||||
|-------------|---------|-------|------|
|
||||
| `'member'` | Provider | 學員取消了預約 | 學員已取消《{課程名稱}》的預約(時段:{日期}) |
|
||||
| `'provider'` | Member | 教練取消了你的預約 | 教練已取消你的《{課程名稱}》預約(時段:{日期}),如有疑問請聯繫教練 |
|
||||
|
||||
`toArray()` 的 `action_url`:
|
||||
- `cancelledBy: 'member'` → `{FRONTEND_URL}/coach/bookings`
|
||||
- `cancelledBy: 'provider'` → `{FRONTEND_URL}/my-bookings/{booking.id}`
|
||||
|
||||
#### Scenario: 文案依角色區分
|
||||
|
||||
- **WHEN** `new BookingCancelledNotification($booking, cancelledBy: 'member')` 的 `toArray()` 被呼叫
|
||||
- **THEN** `title` 為「學員取消了預約」,`action_url` 指向 `/coach/bookings`
|
||||
|
||||
#### Scenario: Provider 取消文案
|
||||
|
||||
- **WHEN** `new BookingCancelledNotification($booking, cancelledBy: 'provider')` 的 `toArray()` 被呼叫
|
||||
- **THEN** `title` 為「教練取消了你的預約」,`action_url` 指向 `/my-bookings/{id}`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 預約取消觸發通知(Member 發起)
|
||||
|
||||
系統 SHALL 在 Member 取消預約(status → `member_cancelled`)時,通知 Provider(站內 + Email)。觸發點在 `MemberBookingController::cancel()` 的 DB transaction commit 之後。
|
||||
|
||||
#### Scenario: Member 取消預約
|
||||
|
||||
- **WHEN** `MemberBookingController::cancel()` 執行,`$booking->update(['status' => BookingStatus::MemberCancelled])`
|
||||
- **THEN** 取得 `$booking->schedule->divingOffer->provider`(Provider),呼叫 `$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'))`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 預約取消觸發通知(Provider 發起)
|
||||
|
||||
系統 SHALL 在 Provider 取消預約(status → `provider_cancelled`)時,通知 Member(站內 + Email)。觸發點在 `ProviderBookingController::cancel()` 的 DB transaction commit 之後。
|
||||
|
||||
#### Scenario: Provider 取消預約
|
||||
|
||||
- **WHEN** `ProviderBookingController::cancel()` 執行,`$booking->update(['status' => BookingStatus::ProviderCancelled])`
|
||||
- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider'))`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 預約完成觸發通知
|
||||
|
||||
系統 SHALL 在預約標記為完成(status → `completed`)時,通知 Member 可前往評價(站內 + Email)。觸發點包含:`ProviderBookingController::complete()`(手動)與 `CompleteFinishedBookings` Command(排程自動完成)。
|
||||
|
||||
#### Scenario: 手動完成
|
||||
|
||||
- **WHEN** `ProviderBookingController::complete()` 執行
|
||||
- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingCompletedNotification($booking))`
|
||||
|
||||
#### Scenario: 排程自動完成(含 N+1 防護)
|
||||
|
||||
- **WHEN** `CompleteFinishedBookings::handle()` 執行
|
||||
- **THEN** 使用 `->with(['member', 'schedule.divingOffer'])->get()` 取得 booking 集合(**禁止 bulk `->update()`**),loop 內逐筆 `$booking->update(status: Completed)` + try/catch notify;單筆 notify 失敗不中斷整個批次
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 收到評價觸發通知
|
||||
|
||||
系統 SHALL 在 Member 成功提交評價後,通知被評價課程的 Provider(僅站內通知,無 Email)。觸發點在 `ReviewController::store()` 的 DB transaction commit 之後。
|
||||
|
||||
取得 Provider 的方式:`DivingOffer::with('provider')->findOrFail($offerId)->provider`(DivingOffer `belongsTo` User)。
|
||||
|
||||
#### Scenario: Member 提交評價
|
||||
|
||||
- **WHEN** `ReviewController::store()` 的 DB transaction 成功,`$review` 建立完成
|
||||
- **THEN** 取得 `$offer->provider`(Provider),呼叫 `$provider->notify(new ReviewReceivedNotification($review))`(僅 `['database']`)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 通知觸發為原子操作,不影響主業務
|
||||
|
||||
所有 notify 呼叫 SHALL 以 `try/catch (\Throwable $e)` 包裹,若失敗僅寫入 Laravel log,不得造成主業務回傳錯誤或 rollback。
|
||||
|
||||
#### Scenario: notify 失敗不影響主業務
|
||||
|
||||
- **WHEN** `$user->notify(...)` 拋出任何例外
|
||||
- **THEN** 預約/評價主業務資料已正確儲存,HTTP response 正常回傳,`\Log::error(...)` 記錄錯誤
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 評價建立後觸發 Provider 通知
|
||||
|
||||
評價系統 SHALL 在 Member 成功建立評價後,通知課程所屬 Provider(僅站內通知,不寄 Email)。`ReviewService::create()` MUST 在評價資料儲存成功後觸發通知,以 try/catch 包裹確保主業務不受影響。
|
||||
|
||||
#### Scenario: 評價成功送出
|
||||
|
||||
- **WHEN** `ReviewService::create()` 建立新評價,`reviews` 資料表寫入成功
|
||||
- **THEN** `$provider->notify(new ReviewReceivedNotification($review))` 被呼叫,Provider 站內通知新增一筆
|
||||
|
||||
#### Scenario: 通知失敗不影響評價建立
|
||||
|
||||
- **WHEN** notify 呼叫失敗(例:DB 寫入通知失敗)
|
||||
- **THEN** 評價資料已正確儲存,HTTP response 成功回傳,錯誤記錄至 log
|
||||
@@ -0,0 +1,96 @@
|
||||
## 0. 前置設定
|
||||
|
||||
- [x] 0.1 [後端] `config/app.php` 加入 `'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173')`
|
||||
- [x] 0.2 [後端] `.env.example` 補上 `FRONTEND_URL=http://localhost:5173`
|
||||
|
||||
## 1. 基礎設施:資料庫與 Queue
|
||||
|
||||
- [x] 1.1 [後端] 執行 `php artisan notifications:table` 產生 notifications migration,確認 schema 欄位正確
|
||||
- [x] 1.2 [後端] 執行 `php artisan queue:table` 產生 jobs / failed_jobs migration(若尚未存在)
|
||||
- [x] 1.3 [後端] 執行 `php artisan migrate` 建立兩張資料表(需 Docker 啟動後執行)
|
||||
- [x] 1.4 [後端] 在 `.env` 設定 `QUEUE_CONNECTION=database`(已存在)
|
||||
- [x] 1.5 [後端] `docker-compose.yml` 新增 `queue-worker` service(`php artisan queue:work --sleep=3 --tries=3`)
|
||||
- [x] 1.6 [後端] `docker-compose.yml` 新增 `mailpit` service(image: `axllent/mailpit`,port 1025/8025),`.env` 設定 `MAIL_HOST=mailpit MAIL_PORT=1025`
|
||||
|
||||
## 2. Notification Classes(後端)
|
||||
|
||||
- [x] 2.1 [後端] 建立 `app/Notifications/BookingCreatedNotification.php`,`via()` 回傳 `['database', 'mail']`,實作 `toArray()` 與 `toMail()`
|
||||
- [x] 2.2 [後端] 建立 `app/Notifications/BookingConfirmedNotification.php`(`via`: database + mail)
|
||||
- [x] 2.3 [後端] 建立 `app/Notifications/BookingRejectedNotification.php`(`via`: database + mail)
|
||||
- [x] 2.4 [後端] 建立 `app/Notifications/BookingCancelledNotification.php`(`via`: database + mail,含 `cancelledBy` 參數)
|
||||
- [x] 2.5 [後端] 建立 `app/Notifications/BookingCompletedNotification.php`(`via`: database + mail)
|
||||
- [x] 2.6 [後端] 建立 `app/Notifications/ReviewReceivedNotification.php`(`via`: database 僅站內)
|
||||
- [x] 2.7 [後端] 所有 Notification class 的 `toArray()` 回傳統一結構:`{ type, title, body, action_url, related_id, related_type }`
|
||||
|
||||
## 3. Email Markdown 模板
|
||||
|
||||
- [x] 3.1 [後端] 建立 `resources/views/emails/notifications/booking-created.blade.php`(Markdown)(改用 toMail() 內聯實作)
|
||||
- [x] 3.2 [後端] 建立 `resources/views/emails/notifications/booking-confirmed.blade.php`
|
||||
- [x] 3.3 [後端] 建立 `resources/views/emails/notifications/booking-rejected.blade.php`
|
||||
- [x] 3.4 [後端] 建立 `resources/views/emails/notifications/booking-cancelled.blade.php`
|
||||
- [x] 3.5 [後端] 建立 `resources/views/emails/notifications/booking-completed.blade.php`
|
||||
- [x] 3.6 [後端] 確認所有模板包含:平台名稱、通知標題、正文、CTA 按鈕(action_url)、底部免責聲明
|
||||
|
||||
## 4. Notification API(後端)
|
||||
|
||||
- [x] 4.1 [後端] 建立 `app/Http/Controllers/Api/NotificationController.php`,實作 `index()`、`unreadCount()`、`markRead()`、`markAllRead()`、`destroy()`
|
||||
- [x] 4.2 [後端] `routes/api.php` 新增路由群組(Sanctum middleware)
|
||||
- [x] 4.3 [後端] `index()` 分頁 20 筆,依 `created_at` DESC,response 含 `unread_count` 與 `meta`
|
||||
- [x] 4.4 [後端] `markRead()` / `destroy()` 驗證通知屬於當前使用者(findOrFail 在 user->notifications() 作用域內自動限制)
|
||||
|
||||
## 5. 業務觸發整合(後端,無 Service 層,直接插入 Controller)
|
||||
|
||||
- [x] 5.1 [後端] `app/Models/DivingOffer.php` 補上 `provider()` 關聯
|
||||
- [x] 5.2 [後端] 確認 `app/Models/User.php` 已使用 `Notifiable` trait(已存在)
|
||||
- [x] 5.3 [後端] `MemberBookingController::store()`:notify `BookingCreatedNotification`
|
||||
- [x] 5.4 [後端] `ProviderBookingController::confirm()`:notify `BookingConfirmedNotification`
|
||||
- [x] 5.5 [後端] `ProviderBookingController::reject()`:notify `BookingRejectedNotification`
|
||||
- [x] 5.6 [後端] `MemberBookingController::cancel()`:notify `BookingCancelledNotification(cancelledBy: 'member')`
|
||||
- [x] 5.7 [後端] `ProviderBookingController::cancel()`:notify `BookingCancelledNotification(cancelledBy: 'provider')`
|
||||
- [x] 5.8 [後端] `ProviderBookingController::complete()`:notify `BookingCompletedNotification`
|
||||
- [x] 5.9 [後端] `CompleteFinishedBookings::handle()`:改為 get()+loop,逐筆 notify
|
||||
- [x] 5.10 [後端] `ReviewController::store()`:notify `ReviewReceivedNotification`
|
||||
|
||||
## 6. 前端 Pinia Store
|
||||
|
||||
- [x] 6.1 [前端] 建立 `frontend/src/stores/notifications.js`,含 state: `{ unreadCount, notifications, isOpen }`
|
||||
- [x] 6.2 [前端] `notificationStore.startPolling()`:登入後立即 fetch 一次,未讀 > 0 每 30 秒、= 0 每 60 秒;count 改變時 clearInterval 重啟新間隔
|
||||
- [x] 6.3 [前端] Page Visibility API 整合:`visibilitychange = hidden` 暫停 interval;`= visible` 立即 fetch 並重啟
|
||||
- [x] 6.4 [前端] `notificationStore.stopPolling()`:登出時 clearInterval + removeEventListener('visibilitychange')
|
||||
- [x] 6.5 [前端] `notificationStore.fetchNotifications()`:呼叫 `GET /api/notifications`,更新 `notifications` 與 `unreadCount`
|
||||
- [x] 6.6 [前端] `notificationStore.markRead(id)` / `markAllRead()` / `remove(id)` actions(markRead 採 Optimistic update)
|
||||
|
||||
## 7. 前端通知元件
|
||||
|
||||
- [x] 7.1 [前端] 建立 `frontend/src/components/NotificationBell.vue`:Bell Icon + 未讀 Badge(紅色,count > 0 才顯示)
|
||||
- [x] 7.2 [前端] 建立 `frontend/src/components/NotificationDrawer.vue`:側邊 Drawer,列出通知列表,每項顯示 title / body(截 80 字)/ 相對時間 / 已讀狀態
|
||||
- [x] 7.3 [前端] Drawer 頂部加「全部標為已讀」按鈕,點擊後呼叫 `markAllRead()`
|
||||
- [x] 7.4 [前端] 點擊通知項目:呼叫 `markRead(id)` 後 `router.push(action_url)`
|
||||
- [x] 7.5 [前端] 點擊通知項目右側刪除 Icon:呼叫 `remove(id)`
|
||||
|
||||
## 8. 整合至 NavBar
|
||||
|
||||
- [x] 8.1 [前端] `frontend/src/components/NavBar.vue`(Member):加入 `<NotificationBell />`
|
||||
- [x] 8.2 [前端] `frontend/src/components/CoachNavBar.vue`(Coach):加入 Bell Icon
|
||||
- [x] 8.3 [前端] `frontend/src/App.vue`:加入 `<NotificationDrawer />`
|
||||
- [x] 8.4 [前端] `frontend/src/stores/auth.js`:setAuth/init 呼叫 startPolling,logout 呼叫 stopPolling
|
||||
- [x] 8.5 [前端] `frontend/src/stores/coachAuth.js`:同上整合 polling 生命週期
|
||||
|
||||
## 9. 手動驗證
|
||||
|
||||
- [x] 9.1 [整合測試] 啟動 Docker Compose(含 queue-worker + mailpit),確認所有 service 正常
|
||||
- [x] 9.2 [整合測試] Member 建立預約 → Provider 站內通知出現 + Mailpit 收到信
|
||||
- [x] 9.3 [整合測試] Provider 確認預約 → Member 站內通知出現 + Email
|
||||
- [x] 9.4 [整合測試] Member 提交評價 → Provider 站內通知出現(無 Email)
|
||||
- [x] 9.5 [整合測試] Bell Icon 未讀 Badge 顯示正確數量,全部標已讀後 Badge 消失
|
||||
- [x] 9.6 [整合測試] 點擊通知項目 → 標記已讀 → 跳轉 action_url
|
||||
- [x] 9.7 [整合測試] Mailpit Web UI(`http://localhost:8025`)確認 Email 格式與 CTA 連結正確
|
||||
|
||||
## 10. 整合測試中發現的 Bug 修正
|
||||
|
||||
- [x] 10.1 [前端] `main.js`:將三個 auth store 的 `init()` 移至 `app.use(router)` 之前執行,修正 `beforeEach` guard 在 store 初始化前跑導致 protected route 被誤踢的問題
|
||||
- [x] 10.2 [後端] `BookingConfirmedNotification` / `BookingRejectedNotification` / `BookingCancelledNotification`:`action_url` 移除 `/{booking.id}` 尾綴,改為 `{FRONTEND_URL}/my-bookings`(前端路由無 `/my-bookings/:id` 詳情頁)
|
||||
- [x] 10.3 [資料庫] 修正已存在的歷史通知中錯誤的 `action_url`(`UPDATE notifications SET data = JSON_SET(...)`)
|
||||
- [x] 10.4 [後端] 建立 `failed_jobs` 資料表(`php artisan queue:failed-table && php artisan migrate`),修正 queue job 失敗時無法寫入錯誤記錄的問題
|
||||
- [x] 10.5 [前端] `notificationAxios.js`:依 `window.location.pathname` 動態選擇 token(`/coach` 開頭優先 `coach_token`,其餘優先 `token`),修正雙 token 環境下通知 API 用錯帳號的問題
|
||||
- [x] 10.6 [前端] `NotificationDrawer.vue`:`clickItem()` 改用 `new URL(action_url).pathname` 提取路徑,取代原本 `replace(window.location.origin, '')` 的不穩定做法
|
||||
@@ -100,3 +100,19 @@ Member SHALL 能查詢自己所有預約的列表及詳情,含課程連結與
|
||||
#### Scenario: 取得單一預約詳情
|
||||
- **WHEN** 已登入 Member 送出 `GET /api/member/bookings/{id}`
|
||||
- **THEN** 系統回傳該 Booking 詳情;若非本人預約則回傳 403
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 預約狀態轉換觸發通知
|
||||
|
||||
預約七狀態機(`pending` / `confirmed` / `completed` / `rejected` / `expired` / `member_cancelled` / `provider_cancelled`)的每個轉換點,系統 SHALL 在狀態成功更新後觸發對應通知(詳見 `notification-triggers` spec)。通知觸發 MUST 在主業務 transaction commit 之後執行,且以 try/catch 包裹,不影響主業務結果。
|
||||
|
||||
#### Scenario: 狀態轉換後通知觸發
|
||||
|
||||
- **WHEN** `BookingService` 中任一狀態轉換方法成功執行
|
||||
- **THEN** 對應的 Notification class 被觸發,不論通知是否成功主業務均正常回傳
|
||||
|
||||
#### Scenario: 通知失敗不影響主業務
|
||||
|
||||
- **WHEN** notify 呼叫拋出例外
|
||||
- **THEN** 預約狀態已正確儲存,HTTP response 成功回傳,錯誤記錄至 Laravel log
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 通知資料模型
|
||||
|
||||
系統 SHALL 使用 Laravel 內建 `notifications` 資料表儲存站內通知,每筆通知包含:`id`(UUID)、`type`(Notification class 名稱)、`notifiable_type` / `notifiable_id`(多型關聯至 User)、`data`(JSON,含 type / title / body / action_url / related_id / related_type)、`read_at`(nullable)、`created_at` / `updated_at`。
|
||||
|
||||
#### Scenario: 通知建立
|
||||
|
||||
- **WHEN** 業務邏輯觸發 `$user->notify(new XxxNotification(...))`
|
||||
- **THEN** `notifications` 資料表新增一筆記錄,`read_at` 為 null
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 取得通知列表 API
|
||||
|
||||
`GET /api/notifications` SHALL 回傳當前登入使用者的通知列表(含已讀/未讀),分頁 20 筆,依 `created_at` DESC 排序。
|
||||
|
||||
Response data 格式:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"type": "booking_confirmed",
|
||||
"title": "預約已確認",
|
||||
"body": "...",
|
||||
"action_url": "http://localhost:5173/my-bookings",
|
||||
"read_at": null,
|
||||
"created_at": "2026-05-17T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"unread_count": 3,
|
||||
"meta": { "current_page": 1, "last_page": 2 }
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: 已登入使用者取得通知
|
||||
|
||||
- **WHEN** 已登入 Member 呼叫 `GET /api/notifications`
|
||||
- **THEN** 回傳 `status: true`,`data` 陣列包含該使用者的通知,最新在前
|
||||
|
||||
#### Scenario: 未登入拒絕存取
|
||||
|
||||
- **WHEN** 未帶 Token 呼叫 `GET /api/notifications`
|
||||
- **THEN** 回傳 HTTP 401
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 取得未讀數量 API
|
||||
|
||||
`GET /api/notifications/unread-count` SHALL 回傳當前使用者未讀通知數量,用於 Polling。
|
||||
|
||||
Response:`{ "status": true, "data": { "count": 3 } }`
|
||||
|
||||
#### Scenario: 有未讀通知
|
||||
|
||||
- **WHEN** 使用者有 3 筆 `read_at = null` 的通知時呼叫
|
||||
- **THEN** 回傳 `count: 3`
|
||||
|
||||
#### Scenario: 無未讀通知
|
||||
|
||||
- **WHEN** 所有通知 `read_at` 均不為 null
|
||||
- **THEN** 回傳 `count: 0`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 標記單一通知為已讀
|
||||
|
||||
`PATCH /api/notifications/{id}/read` SHALL 將指定通知的 `read_at` 設為當前時間。
|
||||
|
||||
#### Scenario: 標記成功
|
||||
|
||||
- **WHEN** 已登入使用者對自己的通知呼叫此 API
|
||||
- **THEN** 回傳 `status: true`,`read_at` 不再為 null
|
||||
|
||||
#### Scenario: 非本人通知拒絕
|
||||
|
||||
- **WHEN** 使用者嘗試標記他人通知
|
||||
- **THEN** 回傳 HTTP 403
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 標記全部通知為已讀
|
||||
|
||||
`PATCH /api/notifications/read-all` SHALL 將當前使用者所有未讀通知一次標記為已讀。
|
||||
|
||||
#### Scenario: 批次標記
|
||||
|
||||
- **WHEN** 使用者有 5 筆未讀,呼叫此 API
|
||||
- **THEN** 所有 5 筆 `read_at` 更新,回傳 `status: true`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 刪除通知
|
||||
|
||||
`DELETE /api/notifications/{id}` SHALL 永久刪除指定通知。
|
||||
|
||||
#### Scenario: 刪除成功
|
||||
|
||||
- **WHEN** 已登入使用者刪除自己的通知
|
||||
- **THEN** 該通知從資料庫移除,回傳 HTTP 204
|
||||
|
||||
#### Scenario: 非本人通知拒絕刪除
|
||||
|
||||
- **WHEN** 使用者嘗試刪除他人通知
|
||||
- **THEN** 回傳 HTTP 403
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 前端 Bell Icon 未讀計數
|
||||
|
||||
NavBar(MemberNavBar + CoachNavBar)SHALL 顯示通知鈴鐺圖示,未讀數量 > 0 時顯示紅色 Badge。
|
||||
|
||||
#### Scenario: 有未讀通知
|
||||
|
||||
- **WHEN** 使用者登入後 Pinia store polling 回傳 `count > 0`
|
||||
- **THEN** Bell Icon 顯示紅色數字 Badge
|
||||
|
||||
#### Scenario: 無未讀通知
|
||||
|
||||
- **WHEN** `count === 0`
|
||||
- **THEN** Badge 不顯示(隱藏,不佔位)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 前端通知中心 Drawer
|
||||
|
||||
點擊 Bell Icon SHALL 開啟側邊 Drawer,列出最新 20 筆通知,每筆顯示 title、body(截斷 80 字)、時間(相對時間)、已讀/未讀狀態。
|
||||
|
||||
#### Scenario: 點擊通知項目
|
||||
|
||||
- **WHEN** 使用者點擊通知項目
|
||||
- **THEN** 通知標記為已讀(Optimistic update),並以 `new URL(action_url).pathname` 提取路徑後呼叫 `router.push()`,跳轉至對應頁面
|
||||
|
||||
#### Scenario: 點擊「全部標記已讀」
|
||||
|
||||
- **WHEN** 使用者點擊 Drawer 頂部「全部標為已讀」按鈕
|
||||
- **THEN** 呼叫 `PATCH /api/notifications/read-all`,所有項目變為已讀樣式
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Polling 機制
|
||||
|
||||
前端 Pinia `notificationStore` SHALL 在使用者登入後立即執行第一次 fetch,並依未讀數量動態調整輪詢間隔:未讀 > 0 → 30 秒;未讀 = 0 → 60 秒。間隔切換時 MUST `clearInterval` 後以新間隔重新建立。登出後清除計時器與 Page Visibility 監聽器。
|
||||
|
||||
#### Scenario: 登入後立即 fetch
|
||||
|
||||
- **WHEN** 使用者成功登入(Member 或 Coach)
|
||||
- **THEN** `notificationStore.startPolling()` 立即呼叫一次 `fetchUnreadCount()`,不等待第一個 interval 到期
|
||||
|
||||
#### Scenario: 有未讀時使用 30 秒間隔
|
||||
|
||||
- **WHEN** `fetchUnreadCount()` 回傳 `count > 0`
|
||||
- **THEN** interval 設為 30 秒(若目前為 60 秒則 clearInterval 重啟)
|
||||
|
||||
#### Scenario: 無未讀時降頻至 60 秒
|
||||
|
||||
- **WHEN** `fetchUnreadCount()` 回傳 `count === 0`
|
||||
- **THEN** interval 設為 60 秒(若目前為 30 秒則 clearInterval 重啟)
|
||||
|
||||
#### Scenario: 頁面切換至背景時暫停
|
||||
|
||||
- **WHEN** `document.visibilityState === 'hidden'`(使用者切換 Tab 或最小化視窗)
|
||||
- **THEN** clearInterval 暫停 polling,不發出 API 請求
|
||||
|
||||
#### Scenario: 頁面重新顯示時恢復
|
||||
|
||||
- **WHEN** `document.visibilityState === 'visible'`(使用者回到此 Tab)
|
||||
- **THEN** 立即執行一次 `fetchUnreadCount()`,然後依最新 count 重啟 interval
|
||||
|
||||
#### Scenario: 登出後停止
|
||||
|
||||
- **WHEN** 使用者登出
|
||||
- **THEN** `notificationStore.stopPolling()` 執行 `clearInterval` 並 `removeEventListener('visibilitychange', ...)`,不再發出任何請求
|
||||
@@ -0,0 +1,73 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Laravel Mail 設定
|
||||
|
||||
系統 SHALL 支援透過 SMTP 寄送 Email 通知。本地開發環境使用 Mailpit(Docker service)攔截所有寄出信件,不真實發送。`.env` 設定:`MAIL_MAILER=smtp`、`MAIL_HOST=mailpit`(Docker service name)、`MAIL_PORT=1025`。
|
||||
|
||||
#### Scenario: 本地環境信件攔截
|
||||
|
||||
- **WHEN** 系統觸發 Email 通知
|
||||
- **THEN** 信件出現在 Mailpit Web UI(`http://localhost:8025`),未真實寄出
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Queue Worker 處理 Email 投遞
|
||||
|
||||
Email 通知 SHALL 透過 Laravel Queue(`QUEUE_CONNECTION=database`)非同步投遞,不阻塞 HTTP response。Queue Worker 在 Docker Compose 中以獨立 service 啟動。
|
||||
|
||||
#### Scenario: Email 加入 Queue
|
||||
|
||||
- **WHEN** 業務邏輯觸發 notify,`via()` 包含 `'mail'`
|
||||
- **THEN** Email job 進入 `jobs` 資料表,HTTP response 立即回傳
|
||||
|
||||
#### Scenario: Queue Worker 處理後寄出
|
||||
|
||||
- **WHEN** queue:work 讀取到 Email job
|
||||
- **THEN** Mailable 被實際執行,信件送至 SMTP(本地為 Mailpit)
|
||||
|
||||
#### Scenario: 失敗重試
|
||||
|
||||
- **WHEN** SMTP 連線失敗
|
||||
- **THEN** Job 重試最多 3 次(`$tries = 3`),超過後寫入 `failed_jobs`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Email Markdown 模板
|
||||
|
||||
每種通知場景 SHALL 有對應的 Laravel Markdown Mailable 模板,存放於 `resources/views/emails/notifications/`。模板須包含:平台名稱(CFDivePlatform)、通知標題、正文、行動連結按鈕(CTA)、底部免責聲明。
|
||||
|
||||
涵蓋場景(共 6 種):
|
||||
- `booking-created.blade.php`(給 Provider)
|
||||
- `booking-confirmed.blade.php`(給 Member)
|
||||
- `booking-rejected.blade.php`(給 Member)
|
||||
- `booking-cancelled.blade.php`(給對方)
|
||||
- `booking-completed.blade.php`(給 Member)
|
||||
- `review-received.blade.php`(給 Provider)
|
||||
|
||||
#### Scenario: Email 內容包含行動連結
|
||||
|
||||
- **WHEN** Member 收到「預約已確認」Email
|
||||
- **THEN** 信件包含「查看預約」按鈕,點擊後導向 `{APP_URL}/my-bookings/{id}`
|
||||
|
||||
#### Scenario: Email 主旨語言
|
||||
|
||||
- **WHEN** 系統寄出任何通知 Email
|
||||
- **THEN** 主旨以繁體中文撰寫(例:「你的預約已確認 — CFDivePlatform」)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Email 通知觸發條件與收件人
|
||||
|
||||
| 事件 | 收件人 | 主旨 |
|
||||
|------|--------|------|
|
||||
| 預約建立(pending) | Provider | 你有新的預約申請 |
|
||||
| 預約確認(confirmed) | Member | 你的預約已確認 |
|
||||
| 預約拒絕(rejected) | Member | 你的預約申請未通過 |
|
||||
| 預約取消(任一方) | 對方 | 預約已取消 |
|
||||
| 預約完成(completed) | Member | 預約完成,歡迎留下評價 |
|
||||
| 收到新評價 | Provider | 你收到了一則新評價 |
|
||||
|
||||
#### Scenario: 預約建立後 Provider 收到 Email
|
||||
|
||||
- **WHEN** Member 成功建立預約(status 為 pending)
|
||||
- **THEN** 課程所屬 Provider 在 Queue 處理後收到「你有新的預約申請」Email
|
||||
@@ -0,0 +1,119 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 預約建立觸發通知
|
||||
|
||||
系統 SHALL 在預約成功建立(status = `pending`)時,通知課程所屬 Provider(站內 + Email)。觸發點在 `MemberBookingController::store()` 的 DB transaction commit 之後。
|
||||
|
||||
#### Scenario: Member 建立預約
|
||||
|
||||
- **WHEN** `MemberBookingController::store()` 成功建立預約並回傳 201
|
||||
- **THEN** 取得 `$booking->schedule->divingOffer->provider`(Provider),呼叫 `$provider->notify(new BookingCreatedNotification($booking))`,以 try/catch 包裹
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 預約確認觸發通知
|
||||
|
||||
系統 SHALL 在 Provider 確認預約(status `pending` → `confirmed`)時,通知 Member(站內 + Email)。觸發點在 `ProviderBookingController::confirm()` 的 DB transaction commit 之後。
|
||||
|
||||
#### Scenario: Provider 確認預約
|
||||
|
||||
- **WHEN** `ProviderBookingController::confirm()` 執行,狀態更新為 `confirmed`
|
||||
- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingConfirmedNotification($booking))`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 預約拒絕觸發通知
|
||||
|
||||
系統 SHALL 在 Provider 拒絕預約(status → `rejected`)時,通知 Member(站內 + Email)。觸發點在 `ProviderBookingController::reject()` 的 `$booking->update()` 之後。
|
||||
|
||||
#### Scenario: Provider 拒絕預約
|
||||
|
||||
- **WHEN** `ProviderBookingController::reject()` 執行
|
||||
- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingRejectedNotification($booking))`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BookingCancelledNotification 文案區分
|
||||
|
||||
`BookingCancelledNotification` SHALL 依建構子參數 `cancelledBy: 'member' | 'provider'` 產生不同文案:
|
||||
|
||||
| cancelledBy | 通知對象 | title | body |
|
||||
|-------------|---------|-------|------|
|
||||
| `'member'` | Provider | 學員取消了預約 | 學員已取消《{課程名稱}》的預約(時段:{日期}) |
|
||||
| `'provider'` | Member | 教練取消了你的預約 | 教練已取消你的《{課程名稱}》預約(時段:{日期}),如有疑問請聯繫教練 |
|
||||
|
||||
`toArray()` 的 `action_url`:
|
||||
- `cancelledBy: 'member'` → `{FRONTEND_URL}/coach/bookings`
|
||||
- `cancelledBy: 'provider'` → `{FRONTEND_URL}/my-bookings/{booking.id}`
|
||||
|
||||
#### Scenario: 文案依角色區分
|
||||
|
||||
- **WHEN** `new BookingCancelledNotification($booking, cancelledBy: 'member')` 的 `toArray()` 被呼叫
|
||||
- **THEN** `title` 為「學員取消了預約」,`action_url` 指向 `/coach/bookings`
|
||||
|
||||
#### Scenario: Provider 取消文案
|
||||
|
||||
- **WHEN** `new BookingCancelledNotification($booking, cancelledBy: 'provider')` 的 `toArray()` 被呼叫
|
||||
- **THEN** `title` 為「教練取消了你的預約」,`action_url` 指向 `/my-bookings/{id}`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 預約取消觸發通知(Member 發起)
|
||||
|
||||
系統 SHALL 在 Member 取消預約(status → `member_cancelled`)時,通知 Provider(站內 + Email)。觸發點在 `MemberBookingController::cancel()` 的 DB transaction commit 之後。
|
||||
|
||||
#### Scenario: Member 取消預約
|
||||
|
||||
- **WHEN** `MemberBookingController::cancel()` 執行,`$booking->update(['status' => BookingStatus::MemberCancelled])`
|
||||
- **THEN** 取得 `$booking->schedule->divingOffer->provider`(Provider),呼叫 `$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'))`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 預約取消觸發通知(Provider 發起)
|
||||
|
||||
系統 SHALL 在 Provider 取消預約(status → `provider_cancelled`)時,通知 Member(站內 + Email)。觸發點在 `ProviderBookingController::cancel()` 的 DB transaction commit 之後。
|
||||
|
||||
#### Scenario: Provider 取消預約
|
||||
|
||||
- **WHEN** `ProviderBookingController::cancel()` 執行,`$booking->update(['status' => BookingStatus::ProviderCancelled])`
|
||||
- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider'))`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 預約完成觸發通知
|
||||
|
||||
系統 SHALL 在預約標記為完成(status → `completed`)時,通知 Member 可前往評價(站內 + Email)。觸發點包含:`ProviderBookingController::complete()`(手動)與 `CompleteFinishedBookings` Command(排程自動完成)。
|
||||
|
||||
#### Scenario: 手動完成
|
||||
|
||||
- **WHEN** `ProviderBookingController::complete()` 執行
|
||||
- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingCompletedNotification($booking))`
|
||||
|
||||
#### Scenario: 排程自動完成(含 N+1 防護)
|
||||
|
||||
- **WHEN** `CompleteFinishedBookings::handle()` 執行
|
||||
- **THEN** 使用 `->with(['member', 'schedule.divingOffer'])->get()` 取得 booking 集合(**禁止 bulk `->update()`**),loop 內逐筆 `$booking->update(status: Completed)` + try/catch notify;單筆 notify 失敗不中斷整個批次
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 收到評價觸發通知
|
||||
|
||||
系統 SHALL 在 Member 成功提交評價後,通知被評價課程的 Provider(僅站內通知,無 Email)。觸發點在 `ReviewController::store()` 的 DB transaction commit 之後。
|
||||
|
||||
取得 Provider 的方式:`DivingOffer::with('provider')->findOrFail($offerId)->provider`(DivingOffer `belongsTo` User)。
|
||||
|
||||
#### Scenario: Member 提交評價
|
||||
|
||||
- **WHEN** `ReviewController::store()` 的 DB transaction 成功,`$review` 建立完成
|
||||
- **THEN** 取得 `$offer->provider`(Provider),呼叫 `$provider->notify(new ReviewReceivedNotification($review))`(僅 `['database']`)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 通知觸發為原子操作,不影響主業務
|
||||
|
||||
所有 notify 呼叫 SHALL 以 `try/catch (\Throwable $e)` 包裹,若失敗僅寫入 Laravel log,不得造成主業務回傳錯誤或 rollback。
|
||||
|
||||
#### Scenario: notify 失敗不影響主業務
|
||||
|
||||
- **WHEN** `$user->notify(...)` 拋出任何例外
|
||||
- **THEN** 預約/評價主業務資料已正確儲存,HTTP response 正常回傳,`\Log::error(...)` 記錄錯誤
|
||||
@@ -79,3 +79,19 @@ Provider 或 Admin SHALL 能手動將 confirmed 預約標記為 completed,讓
|
||||
#### Scenario: Admin 手動完成
|
||||
- **WHEN** Admin 送出 `PUT /api/admin/bookings/{id}/complete`,Booking status 為 `confirmed`
|
||||
- **THEN** Booking status 改為 `completed`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 評價建立後觸發 Provider 通知
|
||||
|
||||
評價系統 SHALL 在 Member 成功建立評價後,通知課程所屬 Provider(僅站內通知,不寄 Email)。`ReviewService::create()` MUST 在評價資料儲存成功後觸發通知,以 try/catch 包裹確保主業務不受影響。
|
||||
|
||||
#### Scenario: 評價成功送出
|
||||
|
||||
- **WHEN** `ReviewService::create()` 建立新評價,`reviews` 資料表寫入成功
|
||||
- **THEN** `$provider->notify(new ReviewReceivedNotification($review))` 被呼叫,Provider 站內通知新增一筆
|
||||
|
||||
#### Scenario: 通知失敗不影響評價建立
|
||||
|
||||
- **WHEN** notify 呼叫失敗(例:DB 寫入通知失敗)
|
||||
- **THEN** 評價資料已正確儲存,HTTP response 成功回傳,錯誤記錄至 log
|
||||
|
||||
Reference in New Issue
Block a user