added media, mutation events, agent instructions
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m4s

This commit is contained in:
Daan Meijer
2026-06-15 22:30:17 +02:00
parent 1d1ca88aea
commit a1adf1da1c
44 changed files with 2083 additions and 287 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ class MessageSent implements ShouldBroadcast
public function broadcastWith(): array
{
return [
'message' => $this->message->load('user'),
'message' => $this->message->load('user', 'media'),
];
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Events;
use App\Models\Mutation;
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 MutationCreated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Mutation $mutation)
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
$chatId = $this->mutation->ledger->dynamic->chat->id;
return [
new PrivateChannel('chats.'.$chatId),
];
}
public function broadcastWith(): array
{
return [
'mutation' => $this->mutation->load('user', 'ledger', 'media'),
'type' => 'created',
];
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Events;
use App\Models\Mutation;
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 MutationUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Mutation $mutation)
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
$chatId = $this->mutation->ledger->dynamic->chat->id;
return [
new PrivateChannel('chats.'.$chatId),
];
}
public function broadcastWith(): array
{
return [
'mutation' => $this->mutation->load('user', 'ledger', 'media'),
'type' => 'updated',
];
}
}
+6 -1
View File
@@ -48,7 +48,12 @@ class DynamicController extends Controller
{
$this->authorize('view', $dynamic);
$dynamic->load('ledgers', 'participants', 'chat.messages.user');
$dynamic->load([
'ledgers.media',
'participants',
'chat.messages.user',
'chat.messages.media'
]);
return Inertia::render('Dynamics/Show', [
'dynamic' => $dynamic,
+34 -3
View File
@@ -6,10 +6,13 @@ use App\Http\Requests\StoreLedgerRequest;
use App\Models\Dynamic;
use App\Models\Ledger;
use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Inertia\Inertia;
class LedgerController extends Controller
{
use AuthorizesRequests;
/**
* Display a listing of the resource.
*/
@@ -31,7 +34,18 @@ class LedgerController extends Controller
*/
public function store(StoreLedgerRequest $request, Dynamic $dynamic)
{
$dynamic->ledgers()->create($request->validated());
$ledger = $dynamic->ledgers()->create($request->except('media'));
if ($request->hasFile('media')) {
foreach ($request->file('media') as $file) {
$path = $file->store('media', 'public');
$ledger->media()->create([
'file_path' => $path,
'file_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
]);
}
}
return redirect()->route('dynamics.show', $dynamic);
}
@@ -39,15 +53,32 @@ class LedgerController extends Controller
/**
* Display the specified resource.
*/
public function show(Dynamic $dynamic, Ledger $ledger)
public function show(Request $request, Dynamic $dynamic, Ledger $ledger)
{
$this->authorize('view', $ledger);
$ledger->load('mutations.user', 'mutations.chat.messages.user');
$dynamic->load('chat', 'participants');
$ledger->load([
'media',
'mutations' => function ($query) {
$query->latest();
},
'mutations.user',
'mutations.media',
'mutations.chat.messages.user',
'mutations.chat.messages.media'
]);
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
return Inertia::render('Ledgers/Show', [
'dynamic' => $dynamic,
'ledger' => $ledger,
'isOwner' => $isOwner,
]);
}
+15 -1
View File
@@ -32,10 +32,24 @@ class MessageController extends Controller
public function store(StoreMessageRequest $request, Chat $chat)
{
$message = $chat->messages()->create([
...$request->validated(),
...$request->except('media'),
'user_id' => $request->user()->id,
]);
if ($request->hasFile('media')) {
foreach ($request->file('media') as $file) {
$path = $file->store('media', 'public');
$message->media()->create([
'file_path' => $path,
'file_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
]);
}
}
// Eager-load relations before broadcasting
$message->load('media', 'user');
broadcast(new MessageSent($message));
return redirect()->back();
+88 -5
View File
@@ -32,15 +32,44 @@ class MutationController extends Controller
*/
public function store(StoreMutationRequest $request, Dynamic $dynamic, Ledger $ledger)
{
DB::transaction(function () use ($request, $ledger) {
$ledger->mutations()->create([
...$request->validated(),
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
// If the user is an owner, default status to 'approved'. Otherwise default to 'pending'.
$status = $isOwner ? 'approved' : 'pending';
$mutation = DB::transaction(function () use ($request, $ledger, $status) {
$mutation = $ledger->mutations()->create([
...$request->except(['media', 'type', 'status']),
'user_id' => $request->user()->id,
'type' => $request->input('type', $request->input('amount') >= 0 ? 'addition' : 'subtraction'),
'status' => $status,
]);
$ledger->increment('score', $request->validated('amount'));
if ($request->hasFile('media')) {
foreach ($request->file('media') as $file) {
$path = $file->store('media', 'public');
$mutation->media()->create([
'file_path' => $path,
'file_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
]);
}
}
// Only increment score if the status is approved!
if ($status === 'approved') {
$ledger->increment('score', $request->validated('amount'));
}
return $mutation;
});
// Broadcast the real-time creation event!
broadcast(new \App\Events\MutationCreated($mutation));
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
}
@@ -65,7 +94,61 @@ class MutationController extends Controller
*/
public function update(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{
//
// 1. Authorize - only owners can update mutation status!
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
if (!$isOwner) {
abort(403, 'Only dynamic owners can approve or reject mutations.');
}
$request->validate([
'status' => ['required', 'string', 'in:approved,rejected'],
]);
$oldStatus = $mutation->status;
$newStatus = $request->input('status');
DB::transaction(function () use ($mutation, $ledger, $oldStatus, $newStatus) {
$mutation->update(['status' => $newStatus]);
// Adjust the ledger score if status transitions to approved or from approved!
if ($oldStatus !== 'approved' && $newStatus === 'approved') {
$ledger->increment('score', $mutation->amount);
} elseif ($oldStatus === 'approved' && $newStatus !== 'approved') {
$ledger->decrement('score', $mutation->amount);
}
});
// Log to Mutation and Dynamic chats
$user = $request->user();
$statusText = strtoupper($newStatus);
$mutationMsg = $mutation->chat->messages()->create([
'user_id' => $user->id,
'content' => "System: Suggestion was {$statusText} by {$user->name}.",
]);
broadcast(new \App\Events\MessageSent($mutationMsg));
if ($newStatus === 'approved') {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id,
'content' => "System: {$user->name} APPROVED the suggestion \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.",
]);
} else {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id,
'content' => "System: {$user->name} REJECTED the suggestion \"{$mutation->description}\" on \"{$ledger->name}\" ledger.",
]);
}
broadcast(new \App\Events\MessageSent($dynamicMsg));
// Broadcast the real-time update event!
broadcast(new \App\Events\MutationUpdated($mutation));
return redirect()->back();
}
/**
+2
View File
@@ -27,6 +27,8 @@ class StoreLedgerRequest extends FormRequest
return [
'name' => ['required', 'string', 'max:255'],
'rules' => ['nullable', 'string'],
'media' => ['nullable', 'array'],
'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'],
];
}
}
@@ -26,6 +26,8 @@ class StoreMessageRequest extends FormRequest
{
return [
'content' => ['required', 'string'],
'media' => ['nullable', 'array'],
'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'],
];
}
}
@@ -29,6 +29,8 @@ class StoreMutationRequest extends FormRequest
'description' => ['required', 'string'],
'type' => ['nullable', 'string'],
'status' => ['nullable', 'string'],
'media' => ['nullable', 'array'],
'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'],
];
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ class Dynamic extends Model
public function participants(): BelongsToMany
{
return $this->belongsToMany(User::class, 'participants');
return $this->belongsToMany(User::class, 'participants')->withPivot('role');
}
public function ledgers(): HasMany
+5
View File
@@ -29,4 +29,9 @@ class Ledger extends Model
{
return $this->hasMany(Mutation::class);
}
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
return $this->morphMany(Media::class, 'mediable');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Media extends Model {
use HasFactory;
protected $fillable = [
'file_path',
'file_name',
'mime_type',
];
protected $appends = ['url'];
public function mediable(): MorphTo {
return $this->morphTo();
}
public function getUrlAttribute(): string {
return asset('storage/' . $this->file_path);
}
}
+5
View File
@@ -27,4 +27,9 @@ class Message extends Model
{
return $this->belongsTo(User::class);
}
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
return $this->morphMany(Media::class, 'mediable');
}
}
+5
View File
@@ -37,6 +37,11 @@ class Mutation extends Model
return $this->morphOne(Chat::class, 'chatable');
}
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
return $this->morphMany(Media::class, 'mediable');
}
protected static function booted(): void
{
static::created(function (Mutation $mutation) {
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Policies;
use App\Models\Mutation;
use App\Models\User;
class MutationPolicy
{
/**
* Determine whether the user can view the mutation.
*/
public function view(User $user, Mutation $mutation): bool
{
$dynamic = $mutation->ledger->dynamic;
return $dynamic->participants()->where('user_id', $user->id)->exists();
}
}
+3
View File
@@ -4,8 +4,10 @@ namespace App\Providers;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\Mutation;
use App\Policies\DynamicPolicy;
use App\Policies\LedgerPolicy;
use App\Policies\MutationPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
@@ -18,6 +20,7 @@ class AuthServiceProvider extends ServiceProvider
protected $policies = [
Dynamic::class => DynamicPolicy::class,
Ledger::class => LedgerPolicy::class,
Mutation::class => MutationPolicy::class,
];
/**