ab24f210a6
## 新增功能 - 會員與教練在 confirmed 預約期間可互傳文字與圖片訊息 - Presence Channel 顯示對方在線狀態(即時加入/離線) - 已讀回執:對方讀取訊息後即時更新 - 預約完成(completed)後訊息封存唯讀 - 圖片上傳使用 intervention/image 處理(移除 EXIF、限制尺寸、強制重新編碼) ## 通知系統 - 收到新訊息時 Bell Icon 即時更新(NotificationCreated via private channel) - 預約列表卡片顯示未讀角標(GET /api/bookings/messages/unread-counts 批次查詢) - 瀏覽器分頁在背景時推送 Web Notification ## 基礎建設 - 引入 Laravel Reverb 作為自架 WebSocket 伺服器 - docker-compose 新增 reverb service(連接 proxy_net,供 NPM 代理) - 前端安裝 laravel-echo + pusher-js,初始化 Echo plugin - Dockerfile 補 GD JPEG/WebP/FreeType 支援 ## 清理 - 移除 test_broadcast.php 與 resources/js/echo.js(Blade 殘留) - 移除 /ping、/testpost 測試路由 - channels.php 改用 class-based 授權語法,移除 debug log - BookingChat.vue otherType 提取為 computed 消除重複 - docker-entrypoint.sh 健康檢查改用 env var 取代硬編碼密碼 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
6.6 KiB
PHP
186 lines
6.6 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\API;
|
||
|
||
use App\Events\MessageRead;
|
||
use App\Events\MessageSent;
|
||
use App\Events\NotificationCreated;
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\Booking;
|
||
use App\Models\BookingMessage;
|
||
use App\Models\User;
|
||
use App\Notifications\NewBookingMessageNotification;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Illuminate\Support\Str;
|
||
use Intervention\Image\ImageManager;
|
||
use Intervention\Image\Drivers\Gd\Driver;
|
||
|
||
class BookingMessageController extends Controller
|
||
{
|
||
private function authorizeParticipant(Request $request, Booking $booking): bool
|
||
{
|
||
$user = $request->user();
|
||
$booking->loadMissing('schedule');
|
||
|
||
if ($user->role === 'member') {
|
||
return $booking->member_id === $user->id;
|
||
}
|
||
|
||
if ($user->role === 'provider') {
|
||
return $booking->schedule->provider_id === $user->id;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
public function unreadCounts(Request $request): JsonResponse
|
||
{
|
||
$user = $request->user();
|
||
$senderType = $user->role === 'member' ? 'member' : 'provider';
|
||
$otherType = $senderType === 'member' ? 'provider' : 'member';
|
||
|
||
if ($senderType === 'member') {
|
||
$bookingIds = Booking::where('member_id', $user->id)
|
||
->whereIn('status', ['confirmed', 'completed'])
|
||
->pluck('id');
|
||
} else {
|
||
$bookingIds = Booking::whereHas('schedule', fn($q) => $q->where('provider_id', $user->id))
|
||
->whereIn('status', ['confirmed', 'completed'])
|
||
->pluck('id');
|
||
}
|
||
|
||
// 一次 query 取得所有 booking 的未讀數(只計對方發的、且 read_at 為 NULL)
|
||
$counts = BookingMessage::whereIn('booking_id', $bookingIds)
|
||
->where('sender_type', $otherType)
|
||
->whereNull('read_at')
|
||
->selectRaw('booking_id, COUNT(*) as count')
|
||
->groupBy('booking_id')
|
||
->pluck('count', 'booking_id');
|
||
|
||
return response()->json(['status' => true, 'data' => $counts]);
|
||
}
|
||
|
||
public function index(Request $request, Booking $booking): JsonResponse
|
||
{
|
||
if (!$this->authorizeParticipant($request, $booking)) {
|
||
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||
}
|
||
|
||
$status = $booking->status->value;
|
||
if (!in_array($status, ['confirmed', 'completed'])) {
|
||
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||
}
|
||
|
||
$messages = $booking->messages()->orderBy('created_at')->get();
|
||
|
||
return response()->json(['status' => true, 'data' => $messages]);
|
||
}
|
||
|
||
public function store(Request $request, Booking $booking): JsonResponse
|
||
{
|
||
if (!$this->authorizeParticipant($request, $booking)) {
|
||
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||
}
|
||
|
||
if ($booking->status->value !== 'confirmed') {
|
||
return response()->json(['status' => false, 'message' => '訊息功能僅在預約確認期間開放'], 403);
|
||
}
|
||
|
||
$request->validate(['type' => 'required|in:text,image']);
|
||
|
||
$user = $request->user();
|
||
$senderType = $user->role === 'member' ? 'member' : 'provider';
|
||
|
||
if ($request->input('type') === 'text') {
|
||
$request->validate(['content' => 'required|string|max:5000']);
|
||
|
||
$message = BookingMessage::create([
|
||
'booking_id' => $booking->id,
|
||
'sender_id' => $user->id,
|
||
'sender_type' => $senderType,
|
||
'type' => 'text',
|
||
'content' => $request->input('content'),
|
||
]);
|
||
} else {
|
||
$request->validate([
|
||
'file' => 'required|file|mimes:jpg,jpeg,png,gif,webp|max:10240',
|
||
]);
|
||
|
||
$manager = new ImageManager(new Driver());
|
||
$image = $manager->read($request->file('file'));
|
||
|
||
if ($image->width() > 2048 || $image->height() > 2048) {
|
||
$image->scaleDown(width: 2048, height: 2048);
|
||
}
|
||
|
||
$uuid = Str::uuid();
|
||
$filename = "booking-images/{$uuid}.jpg";
|
||
$encoded = $image->toJpeg(quality: 85);
|
||
|
||
Storage::disk('public')->put($filename, $encoded);
|
||
$url = Storage::disk('public')->url($filename);
|
||
|
||
$message = BookingMessage::create([
|
||
'booking_id' => $booking->id,
|
||
'sender_id' => $user->id,
|
||
'sender_type' => $senderType,
|
||
'type' => 'image',
|
||
'content' => $url,
|
||
]);
|
||
}
|
||
|
||
broadcast(new MessageSent($message));
|
||
|
||
// 通知另一方(寫入 DB notification,讓 Bell 圖示更新)
|
||
$receiverId = $senderType === 'member'
|
||
? $booking->schedule->provider_id
|
||
: $booking->member_id;
|
||
|
||
$receiver = User::find($receiverId);
|
||
if ($receiver) {
|
||
// 同步寫進 DB(非 queue),確保下一行 broadcast 時 unread count 已是最新
|
||
$receiver->notify(new NewBookingMessageNotification($message, $booking, $user));
|
||
// 通知前端:立刻更新 bell badge,不等 polling
|
||
broadcast(new NotificationCreated($receiver->id));
|
||
}
|
||
|
||
return response()->json(['status' => true, 'data' => $message], 201);
|
||
}
|
||
|
||
public function markRead(Request $request, Booking $booking): JsonResponse
|
||
{
|
||
if (!$this->authorizeParticipant($request, $booking)) {
|
||
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||
}
|
||
|
||
$status = $booking->status->value;
|
||
if (!in_array($status, ['confirmed', 'completed'])) {
|
||
return response()->json(['status' => false, 'message' => 'Forbidden'], 403);
|
||
}
|
||
|
||
$request->validate(['last_read_message_id' => 'required|integer']);
|
||
|
||
$user = $request->user();
|
||
$senderType = $user->role === 'member' ? 'member' : 'provider';
|
||
$otherType = $senderType === 'member' ? 'provider' : 'member';
|
||
|
||
$updated = BookingMessage::where('booking_id', $booking->id)
|
||
->where('sender_type', $otherType)
|
||
->where('id', '<=', $request->input('last_read_message_id'))
|
||
->whereNull('read_at')
|
||
->update(['read_at' => now()]);
|
||
|
||
if ($updated > 0 && $status === 'confirmed') {
|
||
broadcast(new MessageRead(
|
||
$booking->id,
|
||
$senderType,
|
||
$request->input('last_read_message_id'),
|
||
));
|
||
}
|
||
|
||
return response()->json(['status' => true]);
|
||
}
|
||
}
|