diff --git a/app/Events/MessageSent.php b/app/Events/MessageSent.php new file mode 100644 index 0000000..9184139 --- /dev/null +++ b/app/Events/MessageSent.php @@ -0,0 +1,49 @@ +message = $message; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('conversation.' . $this->message->conversation_id), + ]; + } + + /** + * Data to broadcast. + */ + public function broadcastWith(): array + { + return [ + 'message' => new MessageResource($this->message->load('user')), + ]; + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 9d3f5f3..7311a29 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -2,8 +2,8 @@ namespace App\Http\Controllers; -use App\Http\Requests\LoginRequest; -use App\Http\Requests\RegisterRequest; +use App\Http\Requests\Auth\LoginRequest; +use App\Http\Requests\Auth\RegisterRequest; use App\Http\Requests\UpdateUserRequest; use App\Http\Resources\UserResource; use App\Models\User; @@ -33,6 +33,7 @@ class AuthController extends Controller return response()->json([ 'user' => new UserResource($user), + 'token' => $token, ], 201)->cookie('token', $token, 60 * 24 * 30); } @@ -52,6 +53,7 @@ class AuthController extends Controller return response()->json([ 'user' => new UserResource($user), + 'token' => $token, ])->cookie('token', $token, 60 * 24 * 30); } diff --git a/app/Http/Controllers/ConversationController.php b/app/Http/Controllers/ConversationController.php index fb46ec9..0b57e34 100644 --- a/app/Http/Controllers/ConversationController.php +++ b/app/Http/Controllers/ConversationController.php @@ -2,64 +2,96 @@ namespace App\Http\Controllers; +use App\Http\Requests\Messaging\ConversationRequest; +use App\Http\Resources\Messaging\ConversationResource; use App\Models\Conversation; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; class ConversationController extends Controller { /** * Display a listing of the resource. */ - public function index() + public function index(Request $request): AnonymousResourceCollection { - // - } + $conversations = $request->user()->conversations() + ->with(['users', 'latestMessage']) + ->latest() + ->get(); - /** - * Show the form for creating a new resource. - */ - public function create() - { - // + return ConversationResource::collection($conversations); } /** * Store a newly created resource in storage. */ - public function store(Request $request) + public function store(ConversationRequest $request): ConversationResource { - // + $validated = $request->validated(); + + $conversation = Conversation::create([ + 'label' => $validated['label'] ?? null, + 'is_group' => $validated['is_group'] ?? false, + ]); + + $participants = $validated['participants']; + if (!in_array($request->user()->id, $participants)) { + $participants[] = $request->user()->id; + } + + $conversation->users()->sync($participants); + + return new ConversationResource($conversation->load('users')); } /** * Display the specified resource. */ - public function show(Conversation $conversation) + public function show(Request $request, Conversation $conversation): ConversationResource { - // - } + // Check if user is participant + if (!$conversation->participants()->where('user_id', $request->user()->id)->exists()) { + abort(403); + } - /** - * Show the form for editing the specified resource. - */ - public function edit(Conversation $conversation) - { - // + return new ConversationResource($conversation->load(['users', 'latestMessage'])); } /** * Update the specified resource in storage. */ - public function update(Request $request, Conversation $conversation) + public function update(ConversationRequest $request, Conversation $conversation): ConversationResource { - // + if (!$conversation->participants()->where('user_id', $request->user()->id)->exists()) { + abort(403); + } + + $validated = $request->validated(); + + $conversation->update([ + 'label' => $validated['label'] ?? $conversation->label, + 'is_group' => $validated['is_group'] ?? $conversation->is_group, + ]); + + if (isset($validated['participants'])) { + $conversation->users()->sync($validated['participants']); + } + + return new ConversationResource($conversation->load('users')); } /** * Remove the specified resource from storage. */ - public function destroy(Conversation $conversation) + public function destroy(Request $request, Conversation $conversation) { - // + if (!$conversation->participants()->where('user_id', $request->user()->id)->exists()) { + abort(403); + } + + $conversation->delete(); + + return response()->noContent(); } } diff --git a/app/Http/Controllers/MessageController.php b/app/Http/Controllers/MessageController.php index 7b7b6b1..9559490 100644 --- a/app/Http/Controllers/MessageController.php +++ b/app/Http/Controllers/MessageController.php @@ -2,64 +2,88 @@ namespace App\Http\Controllers; +use App\Http\Requests\Messaging\MessageRequest; +use App\Http\Resources\Messaging\MessageResource; +use App\Models\Conversation; use App\Models\Message; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; class MessageController extends Controller { /** - * Display a listing of the resource. + * Display a listing of the resource for a conversation. */ - public function index() + public function index(Request $request, Conversation $conversation): AnonymousResourceCollection { - // - } + if (!$conversation->participants()->where('user_id', $request->user()->id)->exists()) { + abort(403); + } - /** - * Show the form for creating a new resource. - */ - public function create() - { - // + $messages = $conversation->messages() + ->with('user') + ->oldest() + ->get(); + + return MessageResource::collection($messages); } /** * Store a newly created resource in storage. */ - public function store(Request $request) + public function store(MessageRequest $request, Conversation $conversation): MessageResource { - // - } + if (!$conversation->participants()->where('user_id', $request->user()->id)->exists()) { + abort(403); + } - /** - * Display the specified resource. - */ - public function show(Message $message) - { - // - } + $validated = $request->validated(); + + $user = $request->user(); + + $message = $conversation->messages()->create([ + 'user_id' => $user->id, + 'sender_name' => $user->name, + 'sender_avatar_url' => $user->avatar_url, + 'body' => $validated['body'], + ]); - /** - * Show the form for editing the specified resource. - */ - public function edit(Message $message) - { - // - } + broadcast(new \App\Events\MessageSent($message))->toOthers(); - /** - * Update the specified resource in storage. - */ - public function update(Request $request, Message $message) - { - // + return new MessageResource($message->load('user')); } /** * Remove the specified resource from storage. */ - public function destroy(Message $message) + public function destroy(Request $request, Message $message) { - // + if ($message->user_id !== $request->user()->id) { + abort(403); + } + + $message->delete(); + + return response()->noContent(); + } + + /** + * Mark a message as read. + */ + public function markAsRead(Request $request, Message $message) + { + $conversation = $message->conversation; + if (!$conversation->participants()->where('user_id', $request->user()->id)->exists()) { + abort(403); + } + + if ($message->user_id === $request->user()->id) { + // Can't mark your own message as read by you (well you could but it's redundant) + // But let's say anyone in the conversation can mark any message as read. + } + + $message->update(['read_at' => now()]); + + return new MessageResource($message->load('user')); } } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php new file mode 100644 index 0000000..a8dc2ce --- /dev/null +++ b/app/Http/Controllers/UserController.php @@ -0,0 +1,22 @@ +|string> + */ + public function rules(): array + { + return [ + 'label' => 'nullable|string|max:255', + 'is_group' => 'boolean', + 'participants' => 'required|array|min:1', + 'participants.*' => 'exists:users,id', + ]; + } +} diff --git a/app/Http/Requests/Messaging/MessageRequest.php b/app/Http/Requests/Messaging/MessageRequest.php new file mode 100644 index 0000000..4974de9 --- /dev/null +++ b/app/Http/Requests/Messaging/MessageRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'body' => 'required|string', + ]; + } +} diff --git a/app/Http/Resources/Messaging/ConversationResource.php b/app/Http/Resources/Messaging/ConversationResource.php new file mode 100644 index 0000000..3daabe3 --- /dev/null +++ b/app/Http/Resources/Messaging/ConversationResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'label' => $this->label, + 'isGroup' => $this->is_group, + 'participants' => UserResource::collection($this->whenLoaded('users')), + 'lastMessage' => new MessageResource($this->whenLoaded('latestMessage')), + 'createdAt' => $this->created_at, + 'updatedAt' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/Messaging/MessageResource.php b/app/Http/Resources/Messaging/MessageResource.php new file mode 100644 index 0000000..3998fb7 --- /dev/null +++ b/app/Http/Resources/Messaging/MessageResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'senderId' => $this->user_id, + 'senderName' => $this->sender_name, + 'senderAvatarUrl' => $this->sender_avatar_url, + 'body' => $this->body, + 'readAt' => $this->read_at, + 'createdAt' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 5af57f6..ad9a082 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -16,9 +16,10 @@ class UserResource extends JsonResource public function toArray(Request $request): array { return [ + 'id' => $this->id, 'name' => $this->name, 'email' => $this->email, - 'avatar_url' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null, + 'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null, ]; } } diff --git a/app/Http/Resources/todoResource.php b/app/Http/Resources/todoResource.php index 400a66a..0b9442f 100644 --- a/app/Http/Resources/todoResource.php +++ b/app/Http/Resources/todoResource.php @@ -15,8 +15,8 @@ class todoResource extends JsonResource 'name' => $this->name, 'status' => $this->status, 'description' => $this->description, - 'image_url' => $this->image_url ? asset(Storage::url($this->image_url)): null, - 'due_date' => $this->due_date, + 'imageUrl' => $this->image_url ? asset(Storage::url($this->image_url)): null, + 'dueDate' => $this->due_date, 'priority' => $this->priority, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, diff --git a/app/Models/Conversation.php b/app/Models/Conversation.php index 3c7c390..2764790 100644 --- a/app/Models/Conversation.php +++ b/app/Models/Conversation.php @@ -35,6 +35,11 @@ class Conversation extends Model return $this->hasMany(Message::class); } + public function latestMessage(): \Illuminate\Database\Eloquent\Relations\HasOne + { + return $this->hasOne(Message::class)->latestOfMany(); + } + public function users(): BelongsToMany { return $this->belongsToMany(User::class, 'participants') diff --git a/app/Models/User.php b/app/Models/User.php index b247ab4..63ea515 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -57,6 +57,18 @@ class User extends Authenticatable implements FilamentUser return $this->hasMany(Todo::class); } + public function conversations(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + { + return $this->belongsToMany(Conversation::class, 'participants') + ->withTimestamps() + ->using(Participant::class); + } + + public function messages(): HasMany + { + return $this->hasMany(Message::class); + } + public function isAdmin() { return true; diff --git a/routes/api.php b/routes/api.php index 111eb84..b3f0ed1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -21,3 +21,14 @@ Route::middleware('auth:sanctum')->prefix('todos')->group(function (): void { Route::patch('/{todo}', [todoController::class, 'update']); Route::delete('/{todo}', [todoController::class, 'destroy']); }); + +Route::middleware('auth:sanctum')->group(function (): void { + Route::get('users', [\App\Http\Controllers\UserController::class, 'index']); + Route::apiResource('conversations', \App\Http\Controllers\ConversationController::class); + + Route::get('conversations/{conversation}/messages', [\App\Http\Controllers\MessageController::class, 'index']); + Route::post('conversations/{conversation}/messages', [\App\Http\Controllers\MessageController::class, 'store']); + + Route::delete('messages/{message}', [\App\Http\Controllers\MessageController::class, 'destroy']); + Route::patch('messages/{message}/read', [\App\Http\Controllers\MessageController::class, 'markAsRead']); +}); diff --git a/routes/channels.php b/routes/channels.php index df2ad28..5f83b17 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -3,5 +3,12 @@ use Illuminate\Support\Facades\Broadcast; Broadcast::channel('App.Models.User.{id}', function ($user, $id) { - return (int) $user->id === (int) $id; + return (string) $user->id === (string) $id; +}); + +Broadcast::channel('conversation.{conversationId}', function ($user, $conversationId) { + return \App\Models\Conversation::find($conversationId) + ?->participants() + ->where('user_id', $user->id) + ->exists(); });