feat: Implement chat functionality

This commit is contained in:
Daan Meijer 2026-06-15 00:40:24 +02:00
parent 8f2cf8e642
commit f8ee8165ff
16 changed files with 349 additions and 2 deletions

View File

@ -46,7 +46,7 @@ class DynamicController extends Controller
{
$this->authorize('view', $dynamic);
$dynamic->load('ledgers', 'participants');
$dynamic->load('ledgers', 'participants', 'chat.messages.user');
return Inertia::render('Dynamics/Show', [
'dynamic' => $dynamic,

View File

@ -43,7 +43,7 @@ class LedgerController extends Controller
{
$this->authorize('view', $ledger);
$ledger->load('mutations.user');
$ledger->load('mutations.user', 'mutations.chat.messages.user');
return Inertia::render('Ledgers/Show', [
'dynamic' => $dynamic,

View File

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreMessageRequest;
use App\Models\Chat;
use App\Models\Message;
use Illuminate\Http\Request;
class MessageController 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(StoreMessageRequest $request, Chat $chat)
{
$chat->messages()->create([
...$request->validated(),
'user_id' => $request->user()->id,
]);
return redirect()->back();
}
/**
* Display the specified resource.
*/
public function show(Message $message)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Message $message)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Message $message)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Message $message)
{
//
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use App\Models\Chat;
class StoreMessageRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$chat = $this->route('chat');
return $chat && $this->user()->can('view', $chat->chatable);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'content' => ['required', 'string'],
];
}
}

24
app/Models/Chat.php Normal file
View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Chat extends Model
{
/** @use HasFactory<\Database\Factories\ChatFactory> */
use HasFactory;
public function chatable(): MorphTo
{
return $this->morphTo();
}
public function messages(): HasMany
{
return $this->hasMany(Message::class);
}
}

View File

@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
class Dynamic extends Model
{
@ -27,4 +28,16 @@ class Dynamic extends Model
{
return $this->hasMany(Ledger::class);
}
public function chat(): MorphOne
{
return $this->morphOne(Chat::class, 'chatable');
}
protected static function booted(): void
{
static::created(function (Dynamic $dynamic) {
$dynamic->chat()->create([]);
});
}
}

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

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Message extends Model
{
/** @use HasFactory<\Database\Factories\MessageFactory> */
use HasFactory;
protected $fillable = [
'chat_id',
'user_id',
'content',
];
public function chat(): BelongsTo
{
return $this->belongsTo(Chat::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphOne;
class Mutation extends Model
{
@ -30,4 +31,16 @@ class Mutation extends Model
{
return $this->belongsTo(User::class);
}
public function chat(): MorphOne
{
return $this->morphOne(Chat::class, 'chatable');
}
protected static function booted(): void
{
static::created(function (Mutation $mutation) {
$mutation->chat()->create([]);
});
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Models\Chat;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Chat>
*/
class ChatFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
//
];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Models\Message;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Message>
*/
class MessageFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
//
];
}
}

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('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('chat_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('content');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('messages');
}
};

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::create('chats', function (Blueprint $table) {
$table->id();
$table->morphs('chatable');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('chats');
}
};

View File

@ -0,0 +1,48 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
const props = defineProps({
chat: Object,
});
const form = useForm({
content: '',
});
function submit() {
form.post(route('chats.messages.store', props.chat.id), {
onSuccess: () => form.reset(),
});
}
</script>
<template>
<div class="mt-8">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">Chat</h4>
<div class="mt-4 space-y-4">
<div v-for="message in chat.messages" :key="message.id" class="p-4 bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="flex justify-between">
<span class="font-semibold">{{ message.user.name }}</span>
<span class="text-xs text-gray-500">{{ new Date(message.created_at).toLocaleString() }}</span>
</div>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ message.content }}</p>
</div>
<div v-if="chat.messages.length === 0" class="text-gray-500">
No messages yet.
</div>
</div>
<form @submit.prevent="submit" class="mt-6 space-y-6">
<div>
<label for="content" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Message</label>
<textarea v-model="form.content" id="content" rows="3" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm"></textarea>
<div v-if="form.errors.content" class="text-sm text-red-600">{{ form.errors.content }}</div>
</div>
<div class="flex items-center gap-4">
<button type="submit" :disabled="form.processing" class="inline-flex items-center px-4 py-2 bg-gray-800 dark:bg-gray-200 border border-transparent rounded-md font-semibold text-xs text-white dark:text-gray-800 uppercase tracking-widest hover:bg-gray-700 dark:hover:bg-white focus:bg-gray-700 dark:focus:bg-white active:bg-gray-900 dark:active:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150">
Send
</button>
</div>
</form>
</div>
</template>

View File

@ -1,5 +1,6 @@
<script setup>
import AppLayout from '@/layouts/AppLayout.vue';
import Chat from '@/components/Chat.vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
@ -43,6 +44,8 @@ function submit() {
</div>
</div>
<Chat :chat="dynamic.chat" />
<div class="mt-8">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">Participants</h4>
<ul class="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">

View File

@ -1,5 +1,6 @@
<script setup>
import AppLayout from '@/layouts/AppLayout.vue';
import Chat from '@/components/Chat.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
@ -85,6 +86,7 @@ function submit() {
</div>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ mutation.description }}</p>
<div class="mt-2 text-xs text-gray-500">{{ new Date(mutation.created_at).toLocaleString() }}</div>
<Chat :chat="mutation.chat" />
</li>
</ul>
<div v-if="ledger.mutations.length === 0" class="mt-4 text-gray-500">

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\MessageController;
use App\Http\Controllers\MutationController;
use App\Http\Controllers\DynamicController;
use App\Http\Controllers\LedgerController;
@ -13,6 +14,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('dynamics', DynamicController::class);
Route::resource('dynamics.ledgers', LedgerController::class)->scoped();
Route::resource('dynamics.ledgers.mutations', MutationController::class)->scoped();
Route::post('/chats/{chat}/messages', [MessageController::class, 'store'])->name('chats.messages.store');
});
require __DIR__.'/settings.php';