Compare commits

...

5 Commits

35 changed files with 1958 additions and 13 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace App\Events;
use App\Http\Resources\Messaging\MessageResource;
use App\Models\Message;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $message;
/**
* Create a new event instance.
*/
public function __construct(Message $message)
{
$this->message = $message;
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
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')),
];
}
}

View File

@ -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);
}

View File

@ -0,0 +1,97 @@
<?php
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(Request $request): AnonymousResourceCollection
{
$conversations = $request->user()->conversations()
->with(['users', 'latestMessage'])
->latest()
->get();
return ConversationResource::collection($conversations);
}
/**
* Store a newly created resource in storage.
*/
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(Request $request, Conversation $conversation): ConversationResource
{
// Check if user is participant
if (!$conversation->participants()->where('user_id', $request->user()->id)->exists()) {
abort(403);
}
return new ConversationResource($conversation->load(['users', 'latestMessage']));
}
/**
* Update the specified resource in storage.
*/
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(Request $request, Conversation $conversation)
{
if (!$conversation->participants()->where('user_id', $request->user()->id)->exists()) {
abort(403);
}
$conversation->delete();
return response()->noContent();
}
}

View File

@ -0,0 +1,95 @@
<?php
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 for a conversation.
*/
public function index(Request $request, Conversation $conversation): AnonymousResourceCollection
{
if (!$conversation->participants()->where('user_id', $request->user()->id)->exists()) {
abort(403);
}
$messages = $conversation->messages()
->with('user')
->oldest()
->get();
return MessageResource::collection($messages);
}
/**
* Store a newly created resource in storage.
*/
public function store(MessageRequest $request, Conversation $conversation): MessageResource
{
if (!$conversation->participants()->where('user_id', $request->user()->id)->exists()) {
abort(403);
}
$validated = $request->validated();
$user = $request->user();
$imageUrl = null;
if ($request->hasFile('image')) {
$imageUrl = $request->file('image')->store('messages/images', 'public');
}
$message = $conversation->messages()->create([
'user_id' => $user->id,
'sender_name' => $user->name,
'sender_avatar_url' => $user->avatar_url,
'body' => $validated['body'] ?? null,
'image_url' => $imageUrl,
]);
broadcast(new \App\Events\MessageSent($message))->toOthers();
return new MessageResource($message->load('user'));
}
/**
* Remove the specified resource from storage.
*/
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'));
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers;
use App\Models\Participant;
use Illuminate\Http\Request;
class ParticipantController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(Participant $participant)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Participant $participant)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Participant $participant)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Participant $participant)
{
//
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class UserController extends Controller
{
/**
* Display a listing of all users except the authenticated one.
*/
public function index(Request $request): AnonymousResourceCollection
{
// On retourne tous les utilisateurs sauf soi-même pour faciliter la création de conversations
$users = User::all();
return UserResource::collection($users);
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Http\Requests;
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Http\Requests;
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class ConversationRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'label' => 'nullable|string|max:255',
'is_group' => 'boolean',
'participants' => 'required|array|min:1',
'participants.*' => 'exists:users,id',
];
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class MessageRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'body' => 'nullable|string|required_without:image',
'image' => 'nullable|image|max:5120', // Max 5MB
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Resources\Messaging;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use App\Http\Resources\UserResource;
class ConversationResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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,
];
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources\Messaging;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
class MessageResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'senderId' => $this->user_id,
'senderName' => $this->sender_name,
'senderAvatarUrl' => $this->sender_avatar_url,
'body' => $this->body,
'imageUrl' => $this->image_url ? asset(Storage::url($this->image_url)) : null,
'readAt' => $this->read_at,
'createdAt' => $this->created_at,
];
}
}

View File

@ -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,
];
}
}

View File

@ -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,

View File

@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Conversation extends Model
{
use HasFactory, HasUlids, SoftDeletes;
protected $fillable = [
'label',
'is_group',
];
protected function casts(): array
{
return [
'is_group' => 'boolean',
];
}
public function participants(): HasMany
{
return $this->hasMany(Participant::class);
}
public function messages(): HasMany
{
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')
->withTimestamps()
->using(Participant::class); // Optional, but links the pivot back to Participant model if needed.
}
}

41
app/Models/Message.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Message extends Model
{
use HasFactory, HasUlids, SoftDeletes;
protected $fillable = [
'user_id',
'sender_name',
'sender_avatar_url',
'conversation_id',
'body',
'image_url',
'read_at',
];
protected function casts(): array
{
return [
'read_at' => 'datetime',
];
}
public function conversation(): BelongsTo
{
return $this->belongsTo(Conversation::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Participant extends Pivot
{
use HasFactory, HasUlids;
protected $table = 'participants';
public $incrementing = false;
protected $fillable = [
'conversation_id',
'user_id',
];
public function conversation(): BelongsTo
{
return $this->belongsTo(Conversation::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -5,6 +5,7 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
@ -14,7 +15,7 @@ use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements FilamentUser
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasApiTokens;
use HasFactory, Notifiable, HasApiTokens, HasUlids;
/**
* The attributes that are mass assignable.
@ -56,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;

View File

@ -9,6 +9,7 @@ return Application::configure(basePath: dirname(__DIR__))
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {

View File

@ -10,6 +10,7 @@ services:
- 'host.docker.internal:host-gateway'
ports:
- '${APP_PORT:-8003}:80'
- '8080:8080'
environment:
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1

View File

@ -14,6 +14,7 @@
"http-interop/http-factory-guzzle": "^1.2",
"laravel/framework": "^12.0",
"laravel/horizon": "^5.45",
"laravel/reverb": "^1.0",
"laravel/sanctum": "^4.0",
"laravel/scout": "^11.0",
"laravel/tinker": "^2.10.1",

1004
composer.lock generated

File diff suppressed because it is too large Load Diff

82
config/broadcasting.php Normal file
View File

@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

102
config/reverb.php Normal file
View File

@ -0,0 +1,102 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
'rate_limiting' => [
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
],
],
],
],
];

View File

@ -12,7 +12,7 @@ return new class extends Migration
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->ulid('id')->primary();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
@ -29,7 +29,7 @@ return new class extends Migration
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUlid('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');

View File

@ -13,7 +13,7 @@ return new class extends Migration
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->ulidMorphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();

View File

@ -11,7 +11,7 @@ return new class extends Migration {
$table->id();
$table->string('name');
$table->boolean('is_done')->default(false);
$table->foreignId('user_id')->constrained('users');
$table->foreignUlid('user_id')->constrained('users');
$table->timestamps();
});
}

View File

@ -0,0 +1,30 @@
<?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('conversations', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->string('label')->nullable();
$table->boolean('is_group')->default(false);
$table->softDeletesTz();
$table->timestampsTz();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('conversations');
}
};

View File

@ -0,0 +1,35 @@
<?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('messages', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('user_id')->constrained('users')->nullOnDelete();
$table->string('sender_name');
$table->string('sender_avatar_url')->nullable();
$table->foreignUlid('conversation_id')->constrained('conversations')->cascadeOnDelete();
$table->text('body')->nullable();
$table->timestampTz('read_at')->nullable();
$table->softDeletesTz();
$table->timestampsTz();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('messages');
}
};

View File

@ -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('participants', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('conversation_id')->constrained('conversations')->cascadeOnDelete();
$table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete();
$table->timestampsTz();
$table->unique(['conversation_id', 'user_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('participants');
}
};

View File

@ -0,0 +1,28 @@
<?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::table('messages', function (Blueprint $table) {
$table->string('image_url')->nullable()->after('body');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('messages', function (Blueprint $table) {
$table->dropColumn('image_url');
});
}
};

View File

@ -2,3 +2,11 @@ import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allow your team to quickly build robust real-time web applications.
*/
import './echo';

14
resources/js/echo.js Normal file
View File

@ -0,0 +1,14 @@
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});

View File

@ -2,6 +2,7 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Broadcast;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\todoController;
Route::prefix('auth')->group(function (): void {
@ -21,3 +22,15 @@ 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 {
Broadcast::routes();
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']);
});

14
routes/channels.php Normal file
View File

@ -0,0 +1,14 @@
<?php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function ($user, $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();
});