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
+1
View File
@@ -4,6 +4,7 @@ APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost
FRONTEND_URL=http://localhost:5173
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Enums\BookingStatus;
use App\Models\Booking;
use App\Notifications\BookingCompletedNotification;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -14,9 +15,22 @@ class CompleteFinishedBookings extends Command
public function handle(): void
{
$count = Booking::where('status', BookingStatus::Confirmed->value)
$bookings = Booking::with(['member', 'schedule.divingOffer'])
->where('status', BookingStatus::Confirmed->value)
->whereHas('schedule', fn($q) => $q->whereDate('scheduled_date', '<', now()->toDateString()))
->update(['status' => BookingStatus::Completed->value]);
->get();
$count = 0;
foreach ($bookings as $booking) {
$booking->update(['status' => BookingStatus::Completed]);
$count++;
try {
$booking->member->notify(new BookingCompletedNotification($booking));
} catch (\Throwable $e) {
Log::error("BookingCompletedNotification failed for booking #{$booking->id}: " . $e->getMessage());
}
}
Log::info("CompleteFinishedBookings: {$count} completed");
$this->info("CompleteFinishedBookings: {$count} bookings completed.");
@@ -7,9 +7,12 @@ use App\Enums\ScheduleStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Notifications\BookingCreatedNotification;
use App\Notifications\BookingCancelledNotification;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class MemberBookingController extends Controller
{
@@ -84,6 +87,14 @@ class MemberBookingController extends Controller
return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
}
try {
$booking->load('schedule.divingOffer.provider');
$provider = $booking->schedule->divingOffer->provider;
$provider->notify(new BookingCreatedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingCreatedNotification failed: ' . $e->getMessage());
}
return response()->json([
'status' => true,
'message' => '預約已送出,等待教練確認',
@@ -126,6 +137,14 @@ class MemberBookingController extends Controller
}
});
try {
$booking->load('schedule.divingOffer.provider');
$provider = $booking->schedule->divingOffer->provider;
$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'));
} catch (\Throwable $e) {
Log::error('BookingCancelledNotification(member) failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '預約已取消']);
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(Request $request)
{
try {
$user = $request->user();
$unreadCount = $user->unreadNotifications()->count();
$notifications = $user->notifications()
->orderByDesc('created_at')
->paginate(20);
$items = $notifications->map(fn($n) => array_merge($n->data, [
'id' => $n->id,
'read_at' => $n->read_at?->toISOString(),
'created_at' => $n->created_at->toISOString(),
]));
return response()->json([
'status' => true,
'data' => $items,
'unread_count' => $unreadCount,
'meta' => [
'current_page' => $notifications->currentPage(),
'last_page' => $notifications->lastPage(),
'total' => $notifications->total(),
],
]);
} catch (\Throwable) {
return response()->json(['status' => true, 'data' => [], 'unread_count' => 0, 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 0]]);
}
}
public function unreadCount(Request $request)
{
try {
return response()->json([
'status' => true,
'data' => ['count' => $request->user()->unreadNotifications()->count()],
]);
} catch (\Throwable) {
return response()->json(['status' => true, 'data' => ['count' => 0]]);
}
}
public function markRead(Request $request, string $id)
{
$notification = $request->user()->notifications()->findOrFail($id);
$notification->markAsRead();
return response()->json(['status' => true]);
}
public function markAllRead(Request $request)
{
$request->user()->unreadNotifications->markAsRead();
return response()->json(['status' => true]);
}
public function destroy(Request $request, string $id)
{
$notification = $request->user()->notifications()->findOrFail($id);
$notification->delete();
return response()->json(null, 204);
}
}
@@ -6,8 +6,13 @@ use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Notifications\BookingCancelledNotification;
use App\Notifications\BookingCompletedNotification;
use App\Notifications\BookingConfirmedNotification;
use App\Notifications\BookingRejectedNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ProviderBookingController extends Controller
{
@@ -53,6 +58,13 @@ class ProviderBookingController extends Controller
return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
}
try {
$booking->load('member');
$booking->member->notify(new BookingConfirmedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingConfirmedNotification failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '預約已確認', 'data' => $this->formatBooking($booking->fresh(['member', 'schedule.divingOffer']))]);
}
@@ -67,6 +79,13 @@ class ProviderBookingController extends Controller
$booking->update(['status' => BookingStatus::Rejected]);
try {
$booking->load('member');
$booking->member->notify(new BookingRejectedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingRejectedNotification failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '預約已拒絕']);
}
@@ -91,6 +110,13 @@ class ProviderBookingController extends Controller
}
});
try {
$booking->load('member');
$booking->member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider'));
} catch (\Throwable $e) {
Log::error('BookingCancelledNotification(provider) failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '預約已取消']);
}
@@ -105,6 +131,13 @@ class ProviderBookingController extends Controller
$booking->update(['status' => BookingStatus::Completed]);
try {
$booking->load('member');
$booking->member->notify(new BookingCompletedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingCompletedNotification failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '預約已標記為完成']);
}
@@ -9,8 +9,10 @@ use App\Models\DivingOffer;
use App\Models\Review;
use App\Models\ReviewEdit;
use App\Models\ReviewVote;
use App\Notifications\ReviewReceivedNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ReviewController extends Controller
{
@@ -119,6 +121,16 @@ class ReviewController extends Controller
return $review;
});
try {
$offer = DivingOffer::with('provider')->findOrFail($offerId);
$provider = $offer->provider;
if ($provider) {
$provider->notify(new ReviewReceivedNotification($review));
}
} catch (\Throwable $e) {
Log::error('ReviewReceivedNotification failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '評價已送出', 'data' => $this->formatReview($review)], 201);
}
+6
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class DivingOffer extends Model
@@ -47,6 +48,11 @@ class DivingOffer extends Model
: null;
}
public function provider(): BelongsTo
{
return $this->belongsTo(User::class, 'provider_id');
}
public function schedules()
{
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
@@ -0,0 +1,74 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingCancelledNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(
public readonly Booking $booking,
public readonly string $cancelledBy,
) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
if ($this->cancelledBy === 'member') {
return [
'type' => 'booking_cancelled',
'title' => '學員取消了預約',
'body' => "學員已取消《{$offer->title}》的預約(時段:{$date}",
'action_url' => config('app.frontend_url') . '/coach/bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
return [
'type' => 'booking_cancelled',
'title' => '教練取消了你的預約',
'body' => "教練已取消你的《{$offer->title}》預約(時段:{$date}),如有疑問請聯繫教練",
'action_url' => config('app.frontend_url') . '/my-bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
if ($this->cancelledBy === 'member') {
return (new MailMessage)
->subject('預約已取消 — CFDivePlatform')
->greeting('通知')
->line("學員已取消《{$offer->title}》的預約(時段:{$date})。")
->action('查看所有預約', config('app.frontend_url') . '/coach/bookings')
->salutation('CFDivePlatform 團隊');
}
return (new MailMessage)
->subject('你的預約已取消 — CFDivePlatform')
->greeting('通知')
->line("教練已取消你的《{$offer->title}》預約(時段:{$date})。如有疑問,請聯繫課程教練。")
->action('查看預約', config('app.frontend_url') . '/my-bookings')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingCompletedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
return [
'type' => 'booking_completed',
'title' => '課程已完成,歡迎留下評價',
'body' => "你的《{$offer->title}》課程已完成,歡迎分享你的學習心得!",
'action_url' => config('app.frontend_url') . '/courses/' . $offer->id,
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$url = config('app.frontend_url') . '/courses/' . $offer->id;
return (new MailMessage)
->subject('課程完成,歡迎留下評價 — CFDivePlatform')
->greeting('恭喜完成課程!')
->line("你的《{$offer->title}》課程已完成。")
->action('前往評價', $url)
->line('你的評價對其他學員非常有幫助,感謝你的分享!')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingConfirmedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
return [
'type' => 'booking_confirmed',
'title' => '預約已確認',
'body' => "你的《{$offer->title}》課程預約已由教練確認(時段:{$date}",
'action_url' => config('app.frontend_url') . '/my-bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
$url = config('app.frontend_url') . '/my-bookings';
return (new MailMessage)
->subject('你的預約已確認 — CFDivePlatform')
->greeting('好消息!')
->line("你的《{$offer->title}》課程預約已由教練確認(時段:{$date})。")
->action('查看預約', $url)
->line('請準時出席,如需取消請至少提前 24 小時操作。')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingCreatedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
return [
'type' => 'booking_created',
'title' => '你有新的預約申請',
'body' => "學員申請了《{$offer->title}》的預約(時段:{$date}",
'action_url' => config('app.frontend_url') . '/coach/bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
$url = config('app.frontend_url') . '/coach/bookings';
return (new MailMessage)
->subject('你有新的預約申請 — CFDivePlatform')
->greeting('你好!')
->line("學員申請了《{$offer->title}》的預約(時段:{$date})。")
->action('查看預約', $url)
->line('請盡快確認或拒絕此預約申請。')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingRejectedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
return [
'type' => 'booking_rejected',
'title' => '預約申請未通過',
'body' => "你的《{$offer->title}》預約申請(時段:{$date})未通過,請選擇其他時段",
'action_url' => config('app.frontend_url') . '/my-bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
$url = config('app.frontend_url') . '/courses';
return (new MailMessage)
->subject('你的預約申請未通過 — CFDivePlatform')
->greeting('通知')
->line("很抱歉,你的《{$offer->title}》預約申請(時段:{$date})未通過審核。")
->action('瀏覽其他課程', $url)
->line('如有疑問,請聯繫課程教練。')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Notifications;
use App\Models\Review;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class ReviewReceivedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Review $review) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
$offer = $this->review->divingOffer;
return [
'type' => 'review_received',
'title' => '你收到了一則新評價',
'body' => "{$offer->title}》收到了 {$this->review->rating} 星評價",
'action_url' => config('app.frontend_url') . '/coach/reviews',
'related_id' => $this->review->id,
'related_type' => 'review',
];
}
}
+2
View File
@@ -15,6 +15,8 @@ return [
'name' => env('APP_NAME', 'Laravel'),
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
/*
|--------------------------------------------------------------------------
| Application Environment
+6 -2
View File
@@ -17,9 +17,13 @@ return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')],
'allowed_origins' => [
env('FRONTEND_URL', 'http://localhost:5173'),
'http://127.0.0.1:5173',
'http://localhost:5173',
],
'allowed_origins_patterns' => [],
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notifications');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('failed_jobs');
}
};
+31
View File
@@ -122,6 +122,37 @@ services:
db:
condition: service_healthy
queue-worker:
image: cfdive-platform
container_name: cfdive-queue
restart: unless-stopped
working_dir: /var/www/
command: php artisan queue:work --sleep=3 --tries=3 --timeout=60
volumes:
- ./:/var/www
environment:
- DB_CONNECTION=mysql
- DB_HOST=db
- DB_PORT=3306
- DB_DATABASE=CFDivePlatform
- DB_USERNAME=cfdiveuser
- DB_PASSWORD=cfdivepass
networks:
- cfdive-network
depends_on:
app:
condition: service_healthy
mailpit:
image: axllent/mailpit
container_name: cfdive-mailpit
restart: unless-stopped
ports:
- "8025:8025"
- "1025:1025"
networks:
- cfdive-network
redis:
image: redis:alpine
container_name: cfdive-redis
+4 -14
View File
@@ -1,21 +1,10 @@
<script setup>
import { computed, onMounted } from 'vue'
import { useAuthStore } from './stores/auth'
import { useCoachAuthStore } from './stores/coachAuth'
import { useAdminAuthStore } from './stores/adminAuth'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import NavBar from './components/NavBar.vue'
import NotificationDrawer from './components/NotificationDrawer.vue'
const auth = useAuthStore()
const coachAuth = useCoachAuthStore()
const adminAuth = useAdminAuthStore()
const route = useRoute()
onMounted(() => {
auth.init()
coachAuth.init()
adminAuth.init()
})
const route = useRoute()
const isBackofficePage = computed(() =>
route.path.startsWith('/coach') || route.path.startsWith('/admin')
@@ -26,5 +15,6 @@ const isBackofficePage = computed(() =>
<div class="min-h-screen bg-gray-50">
<NavBar v-if="!isBackofficePage" />
<RouterView />
<NotificationDrawer />
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
import axios from 'axios'
const notificationApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
notificationApi.interceptors.request.use((config) => {
// 優先用 coach_token,因為 coach 身份通知優先;member 也可用自己的 token
// 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token
const isCoachPage = window.location.pathname.startsWith('/coach')
const token = isCoachPage
? (localStorage.getItem('coach_token') || localStorage.getItem('token'))
: (localStorage.getItem('token') || localStorage.getItem('coach_token'))
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default notificationApi
+2
View File
@@ -1,6 +1,7 @@
<script setup>
import { useCoachAuthStore } from '../stores/coachAuth'
import { useRouter } from 'vue-router'
import NotificationBell from './NotificationBell.vue'
const coachAuth = useCoachAuthStore()
const router = useRouter()
@@ -27,6 +28,7 @@ async function handleLogout() {
<div class="flex items-center gap-4 text-sm">
<span class="text-gray-400">{{ coachAuth.user?.name }}</span>
<NotificationBell />
<button
@click="handleLogout"
class="bg-gray-700 hover:bg-gray-600 px-4 py-1.5 rounded-full transition"
+2
View File
@@ -1,6 +1,7 @@
<script setup>
import { useAuthStore } from '../stores/auth'
import { useRouter } from 'vue-router'
import NotificationBell from './NotificationBell.vue'
const auth = useAuthStore()
const router = useRouter()
@@ -27,6 +28,7 @@ async function handleLogout() {
</span>
<RouterLink to="/my-bookings" class="hover:text-ocean-100 transition">我的預約</RouterLink>
<RouterLink to="/profile" class="hover:text-ocean-100 transition">個人資料</RouterLink>
<NotificationBell />
<button
@click="handleLogout"
class="bg-ocean-600 hover:bg-ocean-500 px-4 py-1.5 rounded-full transition"
@@ -0,0 +1,32 @@
<script setup>
import { useNotificationStore } from '../stores/notifications'
const store = useNotificationStore()
function toggle() {
if (!store.isOpen) {
store.fetchNotifications()
}
store.isOpen = !store.isOpen
}
</script>
<template>
<button
@click="toggle"
class="relative p-2 rounded-full hover:bg-white/10 transition"
aria-label="通知"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0a3 3 0 11-6 0m6 0H9" />
</svg>
<span
v-if="store.unreadCount > 0"
class="absolute -top-0.5 -right-0.5 min-w-[1.1rem] h-[1.1rem] flex items-center justify-center
bg-red-500 text-white text-[10px] font-bold rounded-full px-0.5 leading-none"
>
{{ store.unreadCount > 99 ? '99+' : store.unreadCount }}
</span>
</button>
</template>
@@ -0,0 +1,115 @@
<script setup>
import { useNotificationStore } from '../stores/notifications'
import { useRouter } from 'vue-router'
const store = useNotificationStore()
const router = useRouter()
function formatTime(iso) {
if (!iso) return ''
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000)
if (m < 1) return '剛剛'
if (m < 60) return `${m} 分鐘前`
const h = Math.floor(m / 60)
if (h < 24) return `${h} 小時前`
return `${Math.floor(h / 24)} 天前`
}
function truncate(text, max = 80) {
return text && text.length > max ? text.slice(0, max) + '…' : text
}
async function clickItem(item) {
await store.markRead(item.id)
store.isOpen = false
if (item.action_url) {
try {
const path = new URL(item.action_url).pathname
await router.push(path)
} catch (e) {
console.error('[NotificationDrawer] navigation failed:', item.action_url, e)
}
}
}
</script>
<template>
<Teleport to="body">
<Transition name="drawer">
<div v-if="store.isOpen" class="fixed inset-0 z-50 flex justify-end">
<div class="absolute inset-0 bg-black/30" @click="store.isOpen = false" />
<div class="relative w-80 sm:w-96 h-full bg-white shadow-2xl flex flex-col">
<div class="flex items-center justify-between px-4 py-3 border-b">
<h2 class="font-semibold text-gray-800">通知</h2>
<div class="flex items-center gap-2">
<button
v-if="store.unreadCount > 0"
@click="store.markAllRead()"
class="text-xs text-blue-600 hover:underline"
>
全部標為已讀
</button>
<button @click="store.isOpen = false" class="text-gray-400 hover:text-gray-600 p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<p v-if="store.notifications.length === 0" class="text-center text-gray-400 text-sm py-12">
目前沒有通知
</p>
<ul v-else>
<li
v-for="item in store.notifications"
:key="item.id"
class="flex items-start gap-3 px-4 py-3 border-b hover:bg-gray-50 transition cursor-pointer"
:class="{ 'bg-blue-50': !item.read_at }"
@click="clickItem(item)"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">{{ item.title }}</p>
<p class="text-xs text-gray-500 mt-0.5 line-clamp-2">{{ truncate(item.body) }}</p>
<p class="text-[10px] text-gray-400 mt-1">{{ formatTime(item.created_at) }}</p>
</div>
<button
@click.stop="store.remove(item.id)"
class="shrink-0 text-gray-300 hover:text-gray-500 p-1 mt-0.5"
title="刪除"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
</ul>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.drawer-enter-active,
.drawer-leave-active {
transition: opacity 0.2s ease;
}
.drawer-enter-active > div:last-child,
.drawer-leave-active > div:last-child {
transition: transform 0.2s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-from > div:last-child,
.drawer-leave-to > div:last-child {
transform: translateX(100%);
}
</style>
+13 -1
View File
@@ -3,8 +3,20 @@ import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
import { useCoachAuthStore } from './stores/coachAuth'
import { useAdminAuthStore } from './stores/adminAuth'
const app = createApp(App)
app.use(createPinia())
const pinia = createPinia()
app.use(pinia)
// 在 router 安裝前同步初始化所有 auth store,
// 確保 beforeEach guard 跑時 isLoggedIn 已反映 localStorage 的實際狀態
useAuthStore().init()
useCoachAuthStore().init()
useAdminAuthStore().init()
app.use(router)
app.mount('#app')
+4
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '../api/axios'
import { useNotificationStore } from './notifications'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
@@ -14,6 +15,7 @@ export const useAuthStore = defineStore('auth', () => {
if (saved) {
token.value = saved
user.value = savedUser ? JSON.parse(savedUser) : null
useNotificationStore().startPolling()
}
}
@@ -22,12 +24,14 @@ export const useAuthStore = defineStore('auth', () => {
token.value = tokenValue
localStorage.setItem('token', tokenValue)
localStorage.setItem('user', JSON.stringify(userData))
useNotificationStore().startPolling()
}
async function logout() {
try {
await api.post('/member/logout')
} catch {}
useNotificationStore().stopPolling()
user.value = null
token.value = null
localStorage.removeItem('token')
+4
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import coachApi from '../api/coachAxios'
import { useNotificationStore } from './notifications'
export const useCoachAuthStore = defineStore('coachAuth', () => {
const user = ref(null)
@@ -14,6 +15,7 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
if (savedToken) {
token.value = savedToken
user.value = savedUser ? JSON.parse(savedUser) : null
useNotificationStore().startPolling()
}
}
@@ -22,12 +24,14 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
token.value = tokenValue
localStorage.setItem('coach_token', tokenValue)
localStorage.setItem('coach_user', JSON.stringify(userData))
useNotificationStore().startPolling()
}
async function logout() {
try {
await coachApi.post('/provider/logout')
} catch {}
useNotificationStore().stopPolling()
user.value = null
token.value = null
localStorage.removeItem('coach_token')
+115
View File
@@ -0,0 +1,115 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../api/notificationAxios'
export const useNotificationStore = defineStore('notifications', () => {
const unreadCount = ref(0)
const notifications = ref([])
const isOpen = ref(false)
let intervalId = null
let currentInterval = null
let visibilityHandler = null
async function fetchUnreadCount() {
try {
const res = await api.get('/notifications/unread-count')
const newCount = res.data?.data?.count ?? 0
if (newCount !== unreadCount.value) {
const wasZero = unreadCount.value === 0
unreadCount.value = newCount
if ((wasZero && newCount > 0) || (!wasZero && newCount === 0)) {
restartInterval()
}
}
} catch (e) {
console.error('[NotificationStore] fetchUnreadCount failed:', e?.response?.status, e?.message)
}
}
async function fetchNotifications() {
try {
const res = await api.get('/notifications')
notifications.value = res.data.data
unreadCount.value = res.data.unread_count
} catch (e) {
console.error('[NotificationStore] fetchNotifications failed:', e?.response?.status, e?.message)
}
}
function getInterval() {
return unreadCount.value > 0 ? 30000 : 60000
}
function restartInterval() {
if (intervalId) clearInterval(intervalId)
const ms = getInterval()
currentInterval = ms
intervalId = setInterval(fetchUnreadCount, ms)
}
function startPolling() {
fetchUnreadCount()
restartInterval()
visibilityHandler = () => {
if (document.visibilityState === 'hidden') {
if (intervalId) clearInterval(intervalId)
intervalId = null
} else {
fetchUnreadCount()
restartInterval()
}
}
document.addEventListener('visibilitychange', visibilityHandler)
}
function stopPolling() {
if (intervalId) clearInterval(intervalId)
intervalId = null
if (visibilityHandler) {
document.removeEventListener('visibilitychange', visibilityHandler)
visibilityHandler = null
}
unreadCount.value = 0
notifications.value = []
isOpen.value = false
}
async function markRead(id) {
const n = notifications.value.find(n => n.id === id)
if (n && !n.read_at) {
n.read_at = new Date().toISOString()
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
try {
await api.patch(`/notifications/${id}/read`)
} catch {}
}
async function markAllRead() {
notifications.value.forEach(n => {
if (!n.read_at) n.read_at = new Date().toISOString()
})
unreadCount.value = 0
try {
await api.patch('/notifications/read-all')
} catch {}
}
async function remove(id) {
const n = notifications.value.find(n => n.id === id)
if (n && !n.read_at) unreadCount.value = Math.max(0, unreadCount.value - 1)
notifications.value = notifications.value.filter(n => n.id !== id)
try {
await api.delete(`/notifications/${id}`)
} catch {}
}
return {
unreadCount, notifications, isOpen,
fetchNotifications, fetchUnreadCount,
startPolling, stopPolling,
markRead, markAllRead, remove,
}
})
@@ -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),前端改用 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)。 |
@@ -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 + Queuedatabase driver,可升級為 Redis)、Laravel MailSMTP/Mailpit 本地測試)
- **前端依賴**Pinia store for notifications、Polling 或 SSE 取得即時未讀數
- **影響範圍**BookingService、ReviewService、Admin 教練審核 controller
@@ -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
@@ -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 未讀計數
NavBarMemberNavBar + CoachNavBarSHALL 顯示通知鈴鐺圖示,未讀數量 > 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 通知。本地開發環境使用 MailpitDocker 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(...)` 記錄錯誤
@@ -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` serviceimage: `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` DESCresponse 含 `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)` actionsmarkRead 採 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 呼叫 startPollinglogout 呼叫 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, '')` 的不穩定做法
+16
View File
@@ -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
+174
View File
@@ -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 未讀計數
NavBarMemberNavBar + CoachNavBarSHALL 顯示通知鈴鐺圖示,未讀數量 > 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
View File
@@ -0,0 +1,73 @@
## ADDED Requirements
### Requirement: Laravel Mail 設定
系統 SHALL 支援透過 SMTP 寄送 Email 通知。本地開發環境使用 MailpitDocker 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(...)` 記錄錯誤
+16
View File
@@ -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
+2 -2
View File
@@ -22,8 +22,8 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<!-- <env name="DB_CONNECTION" value="sqlite"/> -->
<!-- <env name="DB_DATABASE" value=":memory:"/> -->
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
+10
View File
@@ -14,6 +14,7 @@ use App\Http\Controllers\API\CourseImageController;
use App\Http\Controllers\API\AdminStatsController;
use App\Http\Controllers\API\AdminUserController;
use App\Http\Controllers\API\AdminOfferController;
use App\Http\Controllers\API\NotificationController;
// 這裡可以定義 API 路由,例如:
Route::get('/ping', function () {
@@ -143,6 +144,15 @@ Route::middleware(['auth:sanctum', 'admin'])->prefix('admin')->group(function ()
Route::delete('/reviews/{id}', [AdminReviewController::class, 'destroy']);
});
// 通知(Member + Provider 共用)
Route::middleware('auth:sanctum')->prefix('notifications')->group(function () {
Route::get('/', [NotificationController::class, 'index']);
Route::get('/unread-count', [NotificationController::class, 'unreadCount']);
Route::patch('/read-all', [NotificationController::class, 'markAllRead']);
Route::patch('/{id}/read', [NotificationController::class, 'markRead']);
Route::delete('/{id}', [NotificationController::class, 'destroy']);
});
// 需要認證的通用路由
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
+556
View File
@@ -0,0 +1,556 @@
<?php
namespace Tests\Feature;
use App\Enums\BookingStatus;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use App\Models\Review;
use App\Models\ReviewEdit;
use App\Models\ReviewVote;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ReviewTest extends TestCase
{
use RefreshDatabase;
// ── 測試資料建立輔助 ─────────────────────────────────────
private function createMember(array $attrs = []): User
{
return User::factory()->create(array_merge(['role' => 'member'], $attrs));
}
private function createAdmin(): User
{
return User::factory()->create(['role' => 'admin']);
}
private function createOffer(): DivingOffer
{
$provider = User::factory()->create(['role' => 'provider']);
return DivingOffer::create([
'provider_id' => $provider->id,
'title' => '測試潛水課程',
'location' => '台北',
'spot' => '龍洞',
'rating' => 0,
'reviews' => 0,
'price' => 3000,
'badges' => [],
'description' => '測試用課程',
'tag' => 'beginner',
'region' => 'north',
]);
}
private function createCompletedBooking(User $member, DivingOffer $offer): Booking
{
$schedule = CourseSchedule::create([
'diving_offer_id' => $offer->id,
'provider_id' => $offer->provider_id,
'scheduled_date' => now()->subDays(7)->toDateString(),
'start_time' => '09:00:00',
'max_participants' => 10,
'current_participants' => 1,
'status' => 'open',
]);
return Booking::create([
'schedule_id' => $schedule->id,
'member_id' => $member->id,
'participants' => 1,
'total_price' => $offer->price,
'status' => BookingStatus::Completed->value,
]);
}
private function createReview(User $member, DivingOffer $offer, array $attrs = []): Review
{
return Review::create(array_merge([
'diving_offer_id' => $offer->id,
'member_id' => $member->id,
'rating' => 5,
'comment' => '很棒的課程!',
], $attrs));
}
// ── 公開列表 ─────────────────────────────────────────────
public function test_public_list_returns_empty_summary_when_no_reviews(): void
{
$offer = $this->createOffer();
$response = $this->getJson("/api/diving-offers/{$offer->id}/reviews");
$response->assertOk()
->assertJson([
'status' => true,
'data' => [
'summary' => [
'average' => 0,
'total' => 0,
],
'reviews' => [],
],
]);
}
public function test_public_list_returns_distribution_with_all_keys(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createReview($member, $offer, ['rating' => 5]);
$response = $this->getJson("/api/diving-offers/{$offer->id}/reviews");
$dist = $response->json('data.summary.distribution');
$this->assertArrayHasKey('1', $dist);
$this->assertArrayHasKey('2', $dist);
$this->assertArrayHasKey('3', $dist);
$this->assertArrayHasKey('4', $dist);
$this->assertArrayHasKey('5', $dist);
$this->assertEquals(1, $dist['5']);
$this->assertEquals(0, $dist['1']);
}
public function test_public_list_anonymous_user_has_no_is_mine_field(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createReview($member, $offer);
$response = $this->getJson("/api/diving-offers/{$offer->id}/reviews");
$review = $response->json('data.reviews.0');
$this->assertArrayNotHasKey('is_mine', $review);
$this->assertFalse($review['has_voted']);
$this->assertEquals('匿名潛水者', $review['reviewer_name']);
}
public function test_public_list_authenticated_user_has_is_mine_field(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createReview($member, $offer);
$response = $this->actingAs($member)->getJson("/api/diving-offers/{$offer->id}/reviews");
$review = $response->json('data.reviews.0');
$this->assertArrayHasKey('is_mine', $review);
$this->assertTrue($review['is_mine']);
}
public function test_public_list_has_voted_is_true_when_voted(): void
{
$owner = $this->createMember();
$voter = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer);
ReviewVote::create(['review_id' => $review->id, 'member_id' => $voter->id, 'created_at' => now()]);
$response = $this->actingAs($voter)->getJson("/api/diving-offers/{$offer->id}/reviews");
$this->assertTrue($response->json('data.reviews.0.has_voted'));
}
public function test_public_list_sort_by_helpful(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$r1 = $this->createReview($member, $offer, ['rating' => 3, 'helpful_count' => 10]);
// Need a second review but UNIQUE constraint prevents same member/offer
// So create another member
$member2 = $this->createMember();
$r2 = $this->createReview($member2, $offer, ['rating' => 5, 'helpful_count' => 1]);
$response = $this->getJson("/api/diving-offers/{$offer->id}/reviews?sort=helpful");
$ids = $response->json('data.reviews.*.id');
$this->assertEquals($r1->id, $ids[0]);
}
public function test_public_list_sort_by_rating(): void
{
$member1 = $this->createMember();
$member2 = $this->createMember();
$offer = $this->createOffer();
$r1 = $this->createReview($member1, $offer, ['rating' => 2]);
$r2 = $this->createReview($member2, $offer, ['rating' => 5]);
$response = $this->getJson("/api/diving-offers/{$offer->id}/reviews?sort=rating");
$ids = $response->json('data.reviews.*.id');
$this->assertEquals($r2->id, $ids[0]);
}
public function test_public_list_sort_by_newest(): void
{
$member1 = $this->createMember();
$member2 = $this->createMember();
$offer = $this->createOffer();
$r1 = $this->createReview($member1, $offer);
$r2 = $this->createReview($member2, $offer);
$response = $this->getJson("/api/diving-offers/{$offer->id}/reviews?sort=newest");
$ids = $response->json('data.reviews.*.id');
$this->assertEquals($r2->id, $ids[0]);
}
// ── 新增評價 ──────────────────────────────────────────────
public function test_guest_cannot_create_review(): void
{
$offer = $this->createOffer();
$this->postJson('/api/member/reviews', [
'diving_offer_id' => $offer->id,
'rating' => 5,
'comment' => '很棒',
])->assertUnauthorized();
}
public function test_member_cannot_review_without_completed_booking(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->actingAs($member)->postJson('/api/member/reviews', [
'diving_offer_id' => $offer->id,
'rating' => 5,
'comment' => '很棒',
])->assertStatus(403)
->assertJson(['message' => '須完成此課程後才能評價']);
}
public function test_member_can_create_review_with_completed_booking(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createCompletedBooking($member, $offer);
$response = $this->actingAs($member)->postJson('/api/member/reviews', [
'diving_offer_id' => $offer->id,
'rating' => 5,
'comment' => '課程很棒!',
]);
$response->assertStatus(201)
->assertJson(['status' => true]);
$this->assertDatabaseHas('reviews', [
'diving_offer_id' => $offer->id,
'member_id' => $member->id,
'rating' => 5,
]);
}
public function test_create_review_recalculates_offer_rating(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createCompletedBooking($member, $offer);
$this->actingAs($member)->postJson('/api/member/reviews', [
'diving_offer_id' => $offer->id,
'rating' => 4,
'comment' => '不錯',
]);
$offer->refresh();
$this->assertEquals(4.0, $offer->rating);
$this->assertEquals(1, $offer->reviews);
}
public function test_member_cannot_review_same_offer_twice(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createCompletedBooking($member, $offer);
$this->createReview($member, $offer);
$this->actingAs($member)->postJson('/api/member/reviews', [
'diving_offer_id' => $offer->id,
'rating' => 3,
'comment' => '重複',
])->assertStatus(422)
->assertJson(['message' => '已評價,如需修改請使用編輯功能']);
}
public function test_create_review_validates_rating_range(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createCompletedBooking($member, $offer);
$this->actingAs($member)->postJson('/api/member/reviews', [
'diving_offer_id' => $offer->id,
'rating' => 6,
'comment' => '超出範圍',
])->assertStatus(422);
}
// ── 修改評價 ──────────────────────────────────────────────
public function test_member_can_update_own_review(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer);
$this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", [
'rating' => 3,
'comment' => '修改後的評論',
])->assertOk()->assertJson(['status' => true]);
$this->assertEquals(3, $review->fresh()->rating);
}
public function test_member_cannot_update_others_review(): void
{
$owner = $this->createMember();
$other = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer);
$this->actingAs($other)->putJson("/api/member/reviews/{$review->id}", [
'rating' => 1,
])->assertStatus(403)
->assertJson(['message' => '無權修改此評價']);
}
public function test_update_creates_review_edit_and_sets_is_edited(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer, ['rating' => 5, 'comment' => '原始評論']);
$this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", [
'rating' => 4,
'comment' => '修改後',
]);
$this->assertDatabaseHas('review_edits', [
'review_id' => $review->id,
'old_rating' => 5,
]);
$this->assertTrue($review->fresh()->is_edited);
}
public function test_second_update_overwrites_review_edit(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer, ['rating' => 5]);
$this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", ['rating' => 4]);
$this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", ['rating' => 3]);
$this->assertDatabaseCount('review_edits', 1);
$this->assertEquals(3, $review->fresh()->rating);
}
public function test_update_review_recalculates_offer_rating(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer, ['rating' => 5]);
// Manually set offer rating to 5 first
$offer->update(['rating' => 5, 'reviews' => 1]);
$this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", ['rating' => 2]);
$offer->refresh();
$this->assertEquals(2.0, $offer->rating);
}
// ── 刪除評價 ──────────────────────────────────────────────
public function test_member_can_delete_own_review(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer);
$this->actingAs($member)->deleteJson("/api/member/reviews/{$review->id}")
->assertOk()->assertJson(['status' => true]);
$this->assertDatabaseMissing('reviews', ['id' => $review->id]);
}
public function test_member_cannot_delete_others_review(): void
{
$owner = $this->createMember();
$other = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer);
$this->actingAs($other)->deleteJson("/api/member/reviews/{$review->id}")
->assertStatus(403)
->assertJson(['message' => '無權刪除此評價']);
}
public function test_delete_review_recalculates_offer_rating(): void
{
$member1 = $this->createMember();
$member2 = $this->createMember();
$offer = $this->createOffer();
$r1 = $this->createReview($member1, $offer, ['rating' => 4]);
$r2 = $this->createReview($member2, $offer, ['rating' => 2]);
$offer->update(['rating' => 3.0, 'reviews' => 2]);
$this->actingAs($member2)->deleteJson("/api/member/reviews/{$r2->id}");
$offer->refresh();
$this->assertEquals(4.0, $offer->rating);
$this->assertEquals(1, $offer->reviews);
}
public function test_delete_review_resets_offer_rating_to_zero_when_no_reviews(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer);
$offer->update(['rating' => 5.0, 'reviews' => 1]);
$this->actingAs($member)->deleteJson("/api/member/reviews/{$review->id}");
$offer->refresh();
$this->assertEquals(0, $offer->rating);
$this->assertEquals(0, $offer->reviews);
}
// ── 有幫助投票 ────────────────────────────────────────────
public function test_guest_cannot_vote(): void
{
$owner = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer);
$this->postJson("/api/reviews/{$review->id}/helpful")
->assertUnauthorized();
}
public function test_member_cannot_vote_own_review(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer);
$this->actingAs($member)->postJson("/api/reviews/{$review->id}/helpful")
->assertStatus(422)
->assertJson(['message' => '不可對自己的評價投票']);
}
public function test_member_can_vote_helpful(): void
{
$owner = $this->createMember();
$voter = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer);
$response = $this->actingAs($voter)->postJson("/api/reviews/{$review->id}/helpful");
$response->assertOk()
->assertJson(['data' => ['helpful_count' => 1, 'has_voted' => true]]);
$this->assertDatabaseHas('review_votes', [
'review_id' => $review->id,
'member_id' => $voter->id,
]);
}
public function test_second_vote_toggles_off(): void
{
$owner = $this->createMember();
$voter = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer);
$this->actingAs($voter)->postJson("/api/reviews/{$review->id}/helpful");
$response = $this->actingAs($voter)->postJson("/api/reviews/{$review->id}/helpful");
$response->assertOk()
->assertJson(['data' => ['helpful_count' => 0, 'has_voted' => false]]);
$this->assertDatabaseMissing('review_votes', [
'review_id' => $review->id,
'member_id' => $voter->id,
]);
}
public function test_helpful_count_does_not_go_below_zero(): void
{
$owner = $this->createMember();
$voter = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer, ['helpful_count' => 0]);
// Force a vote record without incrementing (edge case simulation)
ReviewVote::create(['review_id' => $review->id, 'member_id' => $voter->id, 'created_at' => now()]);
$response = $this->actingAs($voter)->postJson("/api/reviews/{$review->id}/helpful");
$this->assertGreaterThanOrEqual(0, $response->json('data.helpful_count'));
}
// ── Admin 評價管理 ────────────────────────────────────────
public function test_admin_can_list_all_reviews(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createReview($member, $offer);
$admin = $this->createAdmin();
$response = $this->actingAs($admin)->getJson('/api/admin/reviews');
$response->assertOk()->assertJson(['status' => true]);
$this->assertCount(1, $response->json('data'));
$item = $response->json('data.0');
$this->assertArrayHasKey('offer_title', $item);
$this->assertArrayHasKey('member_email', $item);
$this->assertArrayHasKey('rating', $item);
$this->assertArrayHasKey('comment', $item);
}
public function test_admin_can_delete_review(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer);
$admin = $this->createAdmin();
$this->actingAs($admin)->deleteJson("/api/admin/reviews/{$review->id}")
->assertOk()->assertJson(['status' => true]);
$this->assertDatabaseMissing('reviews', ['id' => $review->id]);
}
public function test_admin_delete_recalculates_offer_rating(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer, ['rating' => 5]);
$offer->update(['rating' => 5.0, 'reviews' => 1]);
$admin = $this->createAdmin();
$this->actingAs($admin)->deleteJson("/api/admin/reviews/{$review->id}");
$offer->refresh();
$this->assertEquals(0, $offer->rating);
$this->assertEquals(0, $offer->reviews);
}
public function test_non_admin_cannot_access_admin_reviews(): void
{
$member = $this->createMember();
$this->actingAs($member)->getJson('/api/admin/reviews')
->assertForbidden();
}
}