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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ return [
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|
||||
+6
-2
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),前端改用 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
|
||||
|
||||
+2
-2
@@ -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"/>
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user