Files
CFDivePlatform/app/Http/Controllers/API/BookingMessageController.php
T
a620906209 ab24f210a6 feat: 即時預約聊天室(realtime booking chat)
## 新增功能
- 會員與教練在 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>
2026-05-28 03:59:14 +08:00

186 lines
6.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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]);
}
}