over naar uuids, policies, predefined mutations aan kunnen passen
Some checks failed
linter / quality (push) Failing after 1m5s
tests / ci (8.4) (push) Failing after 1m7s
tests / ci (8.5) (push) Failing after 1m5s
tests / ci (8.3) (push) Failing after 12m5s

This commit is contained in:
Daan Meijer 2026-06-21 23:09:38 +02:00
parent 06e5600447
commit 1e0782385b
46 changed files with 1287 additions and 160 deletions

View File

@ -11,10 +11,10 @@ class DashboardController extends Controller
public function index(Request $request, ActivityService $activityService) public function index(Request $request, ActivityService $activityService)
{ {
$user = $request->user(); $user = $request->user();
$unreadEntities = $activityService->getUnreadEntitiesGrouped($user); $unreadDynamics = $activityService->getUnreadDynamicsGrouped($user);
return Inertia::render('Dashboard', [ return Inertia::render('Dashboard', [
'unreadEntities' => $unreadEntities, 'unreadDynamics' => $unreadDynamics,
]); ]);
} }
} }

View File

@ -2,8 +2,10 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\StoreDynamicRequest; use App\Http\Resources\DynamicResource;
use App\Http\Requests\UpdateDynamicRequest; use App\Http\Resources\LedgerResource;
use App\Http\Resources\MessageResource;
use App\Http\Resources\UserResource;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Services\ActivityService; use App\Services\ActivityService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -19,7 +21,7 @@ class DynamicController extends Controller
public function index(Request $request) public function index(Request $request)
{ {
return Inertia::render('Dynamics/Index', [ return Inertia::render('Dynamics/Index', [
'dynamics' => $request->user()->dynamics()->get(), 'dynamics' => DynamicResource::collection($request->user()->dynamics()->get()),
]); ]);
} }
@ -54,15 +56,14 @@ class DynamicController extends Controller
$dynamic->load(['ledgers.media', 'participants', 'chat']); $dynamic->load(['ledgers.media', 'participants', 'chat']);
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
return Inertia::render('Dynamics/Show', [ return Inertia::render('Dynamics/Show', [
'dynamic' => $dynamic, 'dynamic' => new DynamicResource($dynamic),
'isOwner' => $isOwner, 'ledgers' => LedgerResource::collection($dynamic->ledgers),
'messages' => $dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20), 'participants' => UserResource::collection($dynamic->participants),
'messages' => MessageResource::collection($dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20)),
'can' => [
'update' => $request->user()->can('update', $dynamic),
],
]); ]);
} }
@ -70,7 +71,7 @@ class DynamicController extends Controller
{ {
$this->authorize('view', $dynamic); $this->authorize('view', $dynamic);
return $dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20); return MessageResource::collection($dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20));
} }
/** /**
@ -81,7 +82,7 @@ class DynamicController extends Controller
$this->authorize('update', $dynamic); $this->authorize('update', $dynamic);
return Inertia::render('Dynamics/Settings', [ return Inertia::render('Dynamics/Settings', [
'dynamic' => $dynamic, 'dynamic' => new DynamicResource($dynamic),
]); ]);
} }

View File

@ -125,6 +125,6 @@ class DynamicInvitationController extends Controller
$invitation->delete(); $invitation->delete();
}); });
return redirect()->route('dynamics.show', $invitation->dynamic_id)->with('success', 'Successfully joined the dynamic!'); return redirect()->route('dynamics.show', $invitation->dynamic)->with('success', 'Successfully joined the dynamic!');
} }
} }

View File

@ -3,6 +3,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\StoreLedgerRequest; use App\Http\Requests\StoreLedgerRequest;
use App\Http\Resources\DynamicResource;
use App\Http\Resources\LedgerResource;
use App\Http\Resources\MessageResource;
use App\Http\Resources\MutationResource;
use App\Http\Resources\UserResource;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Services\ActivityService; use App\Services\ActivityService;
@ -30,7 +35,7 @@ class LedgerController extends Controller
$this->authorize('update', $dynamic); $this->authorize('update', $dynamic);
return Inertia::render('Ledgers/Create', [ return Inertia::render('Ledgers/Create', [
'dynamic' => $dynamic, 'dynamic' => new DynamicResource($dynamic),
]); ]);
} }
@ -39,6 +44,7 @@ class LedgerController extends Controller
*/ */
public function store(StoreLedgerRequest $request, Dynamic $dynamic) public function store(StoreLedgerRequest $request, Dynamic $dynamic)
{ {
$this->authorize('create', [Ledger::class, $dynamic]);
$ledger = $dynamic->ledgers()->create($request->except('media')); $ledger = $dynamic->ledgers()->create($request->except('media'));
if ($request->hasFile('media')) { if ($request->hasFile('media')) {
@ -76,16 +82,16 @@ class LedgerController extends Controller
'mutations.chat', 'mutations.chat',
]); ]);
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
return Inertia::render('Ledgers/Show', [ return Inertia::render('Ledgers/Show', [
'dynamic' => $dynamic, 'dynamic' => new DynamicResource($dynamic),
'ledger' => $ledger, 'ledger' => new LedgerResource($ledger),
'isOwner' => $isOwner, 'mutations' => MutationResource::collection($ledger->mutations),
'messages' => $dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20), 'participants' => UserResource::collection($dynamic->participants),
'messages' => MessageResource::collection($dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20)),
'can' => [
'update' => $request->user()->can('update', $ledger),
'close' => $request->user()->can('close', $ledger),
],
]); ]);
} }
@ -93,23 +99,41 @@ class LedgerController extends Controller
{ {
$this->authorize('view', $ledger); $this->authorize('view', $ledger);
return $dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20); return MessageResource::collection($dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20));
} }
/** /**
* Show the form for editing the specified resource. * Show the form for editing the specified resource.
*/ */
public function edit(Ledger $ledger) public function edit(Dynamic $dynamic, Ledger $ledger)
{ {
// $this->authorize('update', $ledger);
return Inertia::render('Ledgers/Edit', [
'dynamic' => new DynamicResource($dynamic),
'ledger' => new LedgerResource($ledger),
]);
} }
/** /**
* Update the specified resource in storage. * Update the specified resource in storage.
*/ */
public function update(Request $request, Ledger $ledger) public function update(StoreLedgerRequest $request, Dynamic $dynamic, Ledger $ledger)
{ {
// $this->authorize('update', $ledger);
$ledger->update($request->validated());
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
}
public function close(Request $request, Dynamic $dynamic, Ledger $ledger)
{
$this->authorize('close', $ledger);
$ledger->update(['status' => 'closed']);
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
} }
/** /**

View File

@ -2,15 +2,19 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Resources\MutationResource;
use App\Http\Requests\StoreMutationRequest; use App\Http\Requests\StoreMutationRequest;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation; use App\Models\Mutation;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
class MutationController extends Controller class MutationController extends Controller
{ {
use AuthorizesRequests;
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
@ -32,13 +36,10 @@ class MutationController extends Controller
*/ */
public function store(StoreMutationRequest $request, Dynamic $dynamic, Ledger $ledger) public function store(StoreMutationRequest $request, Dynamic $dynamic, Ledger $ledger)
{ {
$isOwner = $dynamic->participants() $this->authorize('create', [Mutation::class, $ledger]);
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
// If the user is an owner, default status to 'approved'. Otherwise default to 'pending'. // If the user is an owner, default status to 'approved'. Otherwise default to 'pending'.
$status = $isOwner ? 'approved' : 'pending'; $status = $request->user()->can('update', $ledger) ? 'approved' : 'pending';
$mutation = DB::transaction(function () use ($request, $ledger, $status) { $mutation = DB::transaction(function () use ($request, $ledger, $status) {
$mutation = $ledger->mutations()->create([ $mutation = $ledger->mutations()->create([
@ -78,7 +79,9 @@ class MutationController extends Controller
*/ */
public function show(Dynamic $dynamic, Ledger $ledger, Mutation $mutation) public function show(Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{ {
// $this->authorize('view', $mutation);
return new MutationResource($mutation);
} }
/** /**
@ -94,15 +97,7 @@ class MutationController extends Controller
*/ */
public function update(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation) public function update(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{ {
// 1. Authorize - only owners can update mutation status! $this->authorize('update', $mutation);
$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([ $request->validate([
'status' => ['required', 'string', 'in:approved,rejected'], 'status' => ['required', 'string', 'in:approved,rejected'],
@ -157,6 +152,15 @@ class MutationController extends Controller
return redirect()->back(); return redirect()->back();
} }
public function void(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{
$this->authorize('void', $mutation);
$mutation->update(['status' => 'voided']);
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
}
/** /**
* Remove the specified resource from storage. * Remove the specified resource from storage.
*/ */

View File

@ -16,21 +16,20 @@ class PredefinedMutationController extends Controller
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
public function index(Dynamic $dynamic, Ledger $ledger) public function index(Dynamic $dynamic)
{ {
$this->authorize('update', $dynamic); $this->authorize('update', $dynamic);
return Inertia::render('Ledgers/PredefinedMutations/Index', [ return Inertia::render('Dynamics/PredefinedMutations/Index', [
'dynamic' => $dynamic, 'dynamic' => $dynamic,
'ledger' => $ledger, 'predefined_mutations' => $dynamic->predefinedMutations()->latest()->get(),
'predefined_mutations' => $ledger->predefinedMutations()->latest()->get(),
]); ]);
} }
/** /**
* Store a newly created resource in storage. * Store a newly created resource in storage.
*/ */
public function store(Request $request, Dynamic $dynamic, Ledger $ledger) public function store(Request $request, Dynamic $dynamic)
{ {
$this->authorize('update', $dynamic); $this->authorize('update', $dynamic);
@ -38,10 +37,55 @@ class PredefinedMutationController extends Controller
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'amount' => ['required', 'integer'], 'amount' => ['required', 'integer'],
'type' => ['required', 'string', 'in:reward,penalty'],
]); ]);
$ledger->predefinedMutations()->create($request->all()); $dynamic->predefinedMutations()->create($request->all());
return redirect()->route('dynamics.ledgers.predefined-mutations.index', [$dynamic, $ledger]); return redirect()->route('dynamics.predefined-mutations.index', $dynamic);
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Dynamic $dynamic, PredefinedMutation $predefinedMutation)
{
$this->authorize('update', $dynamic);
return Inertia::render('Dynamics/PredefinedMutations/Edit', [
'dynamic' => $dynamic,
'predefined_mutation' => $predefinedMutation,
]);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Dynamic $dynamic, PredefinedMutation $predefinedMutation)
{
$this->authorize('update', $dynamic);
$request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'amount' => ['required', 'integer'],
'type' => ['required', 'string', 'in:reward,penalty'],
]);
$predefinedMutation->update($request->all());
return redirect()->route('dynamics.predefined-mutations.index', $dynamic);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Dynamic $dynamic, PredefinedMutation $predefinedMutation)
{
$this->authorize('update', $dynamic);
$predefinedMutation->delete();
return redirect()->route('dynamics.predefined-mutations.index', $dynamic);
} }
} }

View File

@ -48,7 +48,7 @@ class HandleInertiaRequests extends Middleware
} }
$service = app(\App\Services\ActivityService::class); $service = app(\App\Services\ActivityService::class);
return count($service->getUnreadEntitiesGrouped($request->user())); return count($service->getUnreadDynamicsGrouped($request->user()));
}, },
]; ];
} }

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Request;
class BaseResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$data = parent::toArray($request);
if (isset($data['id']) && isset($this->uuid)) {
$data['id'] = $this->uuid;
}
return $data;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class DynamicResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class LedgerResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class MessageResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class MutationResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PredefinedMutationResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -34,6 +34,11 @@ class Dynamic extends Model
return $this->hasMany(DynamicInvitation::class); return $this->hasMany(DynamicInvitation::class);
} }
public function predefinedMutations(): HasMany
{
return $this->hasMany(PredefinedMutation::class);
}
public function chat(): MorphOne public function chat(): MorphOne
{ {
return $this->morphOne(Chat::class, 'chatable'); return $this->morphOne(Chat::class, 'chatable');
@ -41,8 +46,17 @@ class Dynamic extends Model
protected static function booted(): void protected static function booted(): void
{ {
static::creating(function ($model) {
$model->uuid = (string) \Illuminate\Support\Str::uuid();
});
static::created(function (Dynamic $dynamic) { static::created(function (Dynamic $dynamic) {
$dynamic->chat()->create([]); $dynamic->chat()->create([]);
}); });
} }
public function getRouteKeyName()
{
return 'uuid';
}
} }

View File

@ -19,6 +19,7 @@ class Ledger extends Model
'rules', 'rules',
'score', 'score',
'alignment', 'alignment',
'status',
]; ];
public function dynamic(): BelongsTo public function dynamic(): BelongsTo
@ -40,4 +41,16 @@ class Ledger extends Model
{ {
return $this->morphMany(Media::class, 'mediable'); return $this->morphMany(Media::class, 'mediable');
} }
protected static function booted(): void
{
static::creating(function ($model) {
$model->uuid = (string) \Illuminate\Support\Str::uuid();
});
}
public function getRouteKeyName()
{
return 'uuid';
}
} }

View File

@ -50,6 +50,10 @@ class Mutation extends Model
protected static function booted(): void protected static function booted(): void
{ {
static::creating(function ($model) {
$model->uuid = (string) \Illuminate\Support\Str::uuid();
});
static::created(function (Mutation $mutation) { static::created(function (Mutation $mutation) {
$mutation->chat()->create([]); $mutation->chat()->create([]);
@ -87,4 +91,9 @@ class Mutation extends Model
broadcast(new \App\Events\MessageSent($dynamicMsg)); broadcast(new \App\Events\MessageSent($dynamicMsg));
}); });
} }
public function getRouteKeyName()
{
return 'uuid';
}
} }

View File

@ -11,14 +11,27 @@ class PredefinedMutation extends Model
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'ledger_id', 'dynamic_id',
'name', 'name',
'description', 'description',
'amount', 'amount',
'type',
]; ];
public function ledger(): BelongsTo public function dynamic(): BelongsTo
{ {
return $this->belongsTo(Ledger::class); return $this->belongsTo(Dynamic::class);
}
protected static function booted(): void
{
static::creating(function ($model) {
$model->uuid = (string) \Illuminate\Support\Str::uuid();
});
}
public function getRouteKeyName()
{
return 'uuid';
} }
} }

View File

@ -69,4 +69,16 @@ class User extends Authenticatable implements PasskeyUser
'two_factor_confirmed_at' => 'datetime', 'two_factor_confirmed_at' => 'datetime',
]; ];
} }
protected static function booted(): void
{
static::creating(function ($model) {
$model->uuid = (string) \Illuminate\Support\Str::uuid();
});
}
public function getRouteKeyName()
{
return 'uuid';
}
} }

View File

@ -37,7 +37,15 @@ class LedgerPolicy
*/ */
public function update(User $user, Ledger $ledger): bool public function update(User $user, Ledger $ledger): bool
{ {
return false; return $user->can('update', $ledger->dynamic);
}
/**
* Determine whether the user can close the model.
*/
public function close(User $user, Ledger $ledger): bool
{
return $user->can('update', $ledger->dynamic);
} }
/** /**

View File

@ -2,18 +2,39 @@
namespace App\Policies; namespace App\Policies;
use App\Models\Ledger;
use App\Models\Mutation; use App\Models\Mutation;
use App\Models\User; use App\Models\User;
class MutationPolicy class MutationPolicy
{ {
/** /**
* Determine whether the user can view the mutation. * Determine whether the user can create mutations.
*/ */
public function view(User $user, Mutation $mutation): bool public function create(User $user, Ledger $ledger): bool
{ {
$dynamic = $mutation->ledger->dynamic; $dynamic = $ledger->dynamic;
return $dynamic->participants()->where('user_id', $user->id)->exists(); return $dynamic->participants()->where('user_id', $user->id)->exists();
} }
/**
* Determine whether the user can update the mutation.
*/
public function update(User $user, Mutation $mutation): bool
{
$dynamic = $mutation->ledger->dynamic;
return $dynamic->participants()->where('user_id', $user->id)->where('role', 'owner')->exists();
}
/**
* Determine whether the user can void the mutation.
*/
public function void(User $user, Mutation $mutation): bool
{
$dynamic = $mutation->ledger->dynamic;
return $dynamic->participants()->where('user_id', $user->id)->where('role', 'owner')->exists();
}
} }

View File

@ -5,6 +5,7 @@ namespace App\Providers;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
@ -23,6 +24,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
JsonResource::withoutWrapping();
$this->configureDefaults(); $this->configureDefaults();
} }

View File

@ -73,25 +73,15 @@ class ActivityService
/** /**
* Retrieve all activities for a given entity. * Retrieve all activities for a given entity.
*/ */
public function getActivitiesForEntity($entity): array public function getActivitiesForDynamic(Dynamic $dynamic): array
{ {
if ($entity instanceof Dynamic) {
$chatId = $entity->chat->id;
$dynamic = $entity;
} elseif ($entity instanceof Ledger) {
$dynamic = $entity->dynamic;
$chatId = $dynamic->chat->id;
} else {
return [];
}
$participants = $dynamic->participants()->withPivot('display_name')->get(); $participants = $dynamic->participants()->withPivot('display_name')->get();
$participantsMap = $participants->reduce(function ($acc, $p) { $participantsMap = $participants->reduce(function ($acc, $p) {
$acc[$p->id] = $p->pivot->display_name ?? $p->name; $acc[$p->id] = $p->pivot->display_name ?? $p->name;
return $acc; return $acc;
}, []); }, []);
$messages = Message::where('chat_id', $chatId) $messages = Message::where('chat_id', $dynamic->chat->id)
->with(['user', 'subject']) ->with(['user', 'subject'])
->latest() ->latest()
->get(); ->get();
@ -113,27 +103,25 @@ class ActivityService
/** /**
* Get unread activities grouped by active entities (Dynamics, Ledgers) for the given user. * Get unread activities grouped by active entities (Dynamics, Ledgers) for the given user.
*/ */
public function getUnreadEntitiesGrouped(User $user): array public function getUnreadDynamicsGrouped(User $user): array
{ {
$groupedEntities = []; $groupedDynamics = [];
$participatingDynamics = $user->dynamics()->with('ledgers')->get(); $participatingDynamics = $user->dynamics()->with('ledgers')->get();
$entities = $participatingDynamics->concat($participatingDynamics->flatMap(fn ($d) => $d->ledgers)); foreach ($participatingDynamics as $dynamic) {
$readAt = $this->getCursorReadAt($user, $dynamic);
$activities = $this->getActivitiesForDynamic($dynamic);
foreach ($entities as $entity) { $this->partitionAndGroupActivities($activities, $readAt, $dynamic, $groupedDynamics);
$readAt = $this->getCursorReadAt($user, $entity);
$activities = $this->getActivitiesForEntity($entity);
$this->partitionActivities($activities, $readAt, $entity, get_class($entity), $this->getUrlForEntity($entity), $groupedEntities);
} }
return $groupedEntities; return $groupedDynamics;
} }
/** /**
* Partition activities into read and unread, and construct the grouped entity metadata. * Partition activities into read and unread, and construct the grouped entity metadata.
*/ */
private function partitionActivities(array $activities, \Carbon\CarbonInterface $readAt, $entity, string $type, string $url, array &$groupedEntities): void private function partitionAndGroupActivities(array $activities, \Carbon\CarbonInterface $readAt, Dynamic $dynamic, array &$groupedDynamics): void
{ {
$alreadyRead = []; $alreadyRead = [];
$unread = []; $unread = [];
@ -149,11 +137,10 @@ class ActivityService
if (!empty($unread)) { if (!empty($unread)) {
$context = array_slice($alreadyRead, 0, 2); $context = array_slice($alreadyRead, 0, 2);
$groupedEntities[] = [ $groupedDynamics[] = [
'id' => $entity->id, 'id' => $dynamic->id,
'name' => $entity->name, 'name' => $dynamic->name,
'type' => Str::afterLast($type, '\\'), 'url' => route('dynamics.show', $dynamic->uuid),
'url' => $url,
'unread_count' => count($unread), 'unread_count' => count($unread),
'context_activities' => $context, 'context_activities' => $context,
'new_activities' => array_reverse($unread), 'new_activities' => array_reverse($unread),
@ -164,11 +151,11 @@ class ActivityService
private function getUrlForEntity($entity): string private function getUrlForEntity($entity): string
{ {
if ($entity instanceof Dynamic) { if ($entity instanceof Dynamic) {
return route('dynamics.show', $entity->id); return route('dynamics.show', $entity->uuid);
} }
if ($entity instanceof Ledger) { if ($entity instanceof Ledger) {
return route('dynamics.ledgers.show', [$entity->dynamic_id, $entity->id]); return route('dynamics.ledgers.show', [$entity->dynamic->uuid, $entity->uuid]);
} }
return ''; return '';

View File

@ -18,7 +18,7 @@ return new class extends Migration
$table->string('type'); $table->string('type');
$table->integer('amount'); $table->integer('amount');
$table->text('description')->nullable(); $table->text('description')->nullable();
$table->string('status')->default('pending'); $table->string('status')->default('pending'); // pending, approved, rejected, voided
$table->timestamps(); $table->timestamps();
}); });
} }

View File

@ -13,10 +13,11 @@ return new class extends Migration
{ {
Schema::create('predefined_mutations', function (Blueprint $table) { Schema::create('predefined_mutations', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('ledger_id')->constrained()->cascadeOnDelete(); $table->foreignId('dynamic_id')->constrained()->cascadeOnDelete();
$table->string('name'); $table->string('name');
$table->text('description')->nullable(); $table->text('description')->nullable();
$table->integer('amount'); $table->integer('amount');
$table->string('type')->default('reward');
$table->timestamps(); $table->timestamps();
}); });
} }

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('ledgers', function (Blueprint $table) {
$table->string('status')->default('open');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->dropColumn('status');
});
}
};

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('mutations', function (Blueprint $table) {
$table->string('status')->default('pending')->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->string('status')->default('pending')->change();
});
}
};

View File

@ -0,0 +1,38 @@
<?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('dynamics', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
\App\Models\Dynamic::all()->each(function ($model) {
$model->uuid = \Illuminate\Support\Str::uuid();
$model->save();
});
Schema::table('dynamics', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('dynamics', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View File

@ -0,0 +1,38 @@
<?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('ledgers', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
\App\Models\Ledger::all()->each(function ($model) {
$model->uuid = \Illuminate\Support\Str::uuid();
$model->save();
});
Schema::table('ledgers', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View File

@ -0,0 +1,38 @@
<?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('mutations', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
\App\Models\Mutation::all()->each(function ($model) {
$model->uuid = \Illuminate\Support\Str::uuid();
$model->save();
});
Schema::table('mutations', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View File

@ -0,0 +1,38 @@
<?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('predefined_mutations', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
\App\Models\PredefinedMutation::all()->each(function ($model) {
$model->uuid = \Illuminate\Support\Str::uuid();
$model->save();
});
Schema::table('predefined_mutations', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('predefined_mutations', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View File

@ -0,0 +1,38 @@
<?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('users', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
\App\Models\User::all()->each(function ($model) {
$model->uuid = \Illuminate\Support\Str::uuid();
$model->save();
});
Schema::table('users', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View File

@ -40,6 +40,16 @@ function updateStatus(mutationId: number, status: 'approved' | 'rejected') {
); );
} }
function voidMutation(mutationId: number) {
useForm({}).put(
route('dynamics.ledgers.mutations.void', {
dynamic: props.dynamicId,
ledger: props.ledgerId,
mutation: mutationId,
}),
);
}
function isOwnerUser(userId: number): boolean { function isOwnerUser(userId: number): boolean {
const participant = props.participants?.find((p) => p.id === userId); const participant = props.participants?.find((p) => p.id === userId);
@ -157,21 +167,30 @@ function getAmountClass(amount: number): string {
<!-- Owner Approve/Reject Actions --> <!-- Owner Approve/Reject Actions -->
<div <div
v-if="isOwner && mutation.status === 'pending'" v-if="isOwner && (mutation.status === 'pending' || mutation.status === 'approved')"
class="c-mutation-list__actions" class="c-mutation-list__actions"
> >
<button <button
v-if="mutation.status === 'pending'"
@click="updateStatus(mutation.id, 'approved')" @click="updateStatus(mutation.id, 'approved')"
class="c-mutation-list__approve-btn" class="c-mutation-list__approve-btn"
> >
Approve Approve
</button> </button>
<button <button
v-if="mutation.status === 'pending'"
@click="updateStatus(mutation.id, 'rejected')" @click="updateStatus(mutation.id, 'rejected')"
class="c-mutation-list__reject-btn" class="c-mutation-list__reject-btn"
> >
Reject Reject
</button> </button>
<button
v-if="mutation.status !== 'voided'"
@click="voidMutation(mutation.id)"
class="c-mutation-list__void-btn"
>
Void
</button>
</div> </div>
<Chat :chat="mutation.chat" :dynamic-id="dynamicId" :participants="participants" /> <Chat :chat="mutation.chat" :dynamic-id="dynamicId" :participants="participants" />
@ -290,6 +309,10 @@ function getAmountClass(amount: number): string {
@apply inline-flex cursor-pointer items-center rounded bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500; @apply inline-flex cursor-pointer items-center rounded bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500;
} }
.c-mutation-list__void-btn {
@apply inline-flex cursor-pointer items-center rounded bg-gray-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-gray-500;
}
.c-mutation-list__empty { .c-mutation-list__empty {
@apply mt-4 text-gray-500; @apply mt-4 text-gray-500;
} }

View File

@ -14,10 +14,9 @@ defineOptions({
}); });
defineProps<{ defineProps<{
unreadEntities: Array<{ unreadDynamics: Array<{
id: number; id: number;
name: string; name: string;
type: 'Dynamic' | 'Ledger';
url: string; url: string;
unread_count: number; unread_count: number;
context_activities: Array<{ context_activities: Array<{
@ -51,34 +50,27 @@ function formatTime(isoString: string): string {
<div class="c-dashboard__container"> <div class="c-dashboard__container">
<h2 class="c-dashboard__title">Recent Activity</h2> <h2 class="c-dashboard__title">Recent Activity</h2>
<div v-if="unreadEntities.length > 0" class="c-dashboard__grid"> <div v-if="unreadDynamics.length > 0" class="c-dashboard__grid">
<div <div
v-for="entity in unreadEntities" v-for="dynamic in unreadDynamics"
:key="`${entity.type}_${entity.id}`" :key="dynamic.id"
class="c-dashboard__card" class="c-dashboard__card"
> >
<div class="c-dashboard__card-header"> <div class="c-dashboard__card-header">
<div class="c-dashboard__entity-meta"> <div class="c-dashboard__entity-meta">
<span <span class="c-dashboard__badge-type c-dashboard__badge-type--dynamic">
:class="[ Dynamic
'c-dashboard__badge-type',
entity.type === 'Dynamic'
? 'c-dashboard__badge-type--dynamic'
: 'c-dashboard__badge-type--ledger',
]"
>
{{ entity.type }}
</span> </span>
<span class="c-dashboard__unread-count"> <span class="c-dashboard__unread-count">
{{ entity.unread_count }} New {{ dynamic.unread_count }} New
</span> </span>
</div> </div>
<Link <Link
:href="entity.url" :href="dynamic.url"
class="c-dashboard__entity-link" class="c-dashboard__entity-link"
> >
<h3 class="c-dashboard__entity-title"> <h3 class="c-dashboard__entity-title">
{{ entity.name }} {{ dynamic.name }}
</h3> </h3>
</Link> </Link>
</div> </div>
@ -86,7 +78,7 @@ function formatTime(isoString: string): string {
<div class="c-dashboard__activity-list"> <div class="c-dashboard__activity-list">
<!-- Context / Read Activities --> <!-- Context / Read Activities -->
<div <div
v-for="activity in entity.context_activities" v-for="activity in dynamic.context_activities"
:key="activity.id" :key="activity.id"
class="c-dashboard__activity-item c-dashboard__activity-item--read" class="c-dashboard__activity-item c-dashboard__activity-item--read"
> >
@ -107,7 +99,7 @@ function formatTime(isoString: string): string {
<!-- Unread Separator Line --> <!-- Unread Separator Line -->
<div <div
v-if="entity.new_activities.length > 0" v-if="dynamic.new_activities.length > 0"
class="c-dashboard__divider" class="c-dashboard__divider"
> >
<span class="c-dashboard__divider-text" <span class="c-dashboard__divider-text"
@ -117,7 +109,7 @@ function formatTime(isoString: string): string {
<!-- New / Unread Activities --> <!-- New / Unread Activities -->
<div <div
v-for="activity in entity.new_activities" v-for="activity in dynamic.new_activities"
:key="activity.id" :key="activity.id"
class="c-dashboard__activity-item c-dashboard__activity-item--unread" class="c-dashboard__activity-item c-dashboard__activity-item--unread"
> >

View File

@ -1,10 +1,14 @@
<script setup> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
defineProps({ defineProps<{
dynamics: Array, dynamics: Array<{
}); id: string;
name: string;
rules: string;
}>;
}>();
const breadcrumbs = [ const breadcrumbs = [
{ {

View File

@ -0,0 +1,154 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
predefined_mutation: {
id: number;
name: string;
description: string;
amount: number;
type: string;
};
}>();
const form = useForm({
name: props.predefined_mutation.name,
description: props.predefined_mutation.description,
amount: props.predefined_mutation.amount,
type: props.predefined_mutation.type,
});
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
name: 'Predefined Mutations',
href: route('dynamics.predefined-mutations.index', props.dynamic.id),
},
{
name: 'Edit',
href: route('dynamics.predefined-mutations.edit', [props.dynamic.id, props.predefined_mutation.id]),
},
];
function submit() {
form.put(route('dynamics.predefined-mutations.update', [props.dynamic.id, props.predefined_mutation.id]));
}
</script>
<template>
<Head title="Edit Predefined Mutation" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="c-predefined-mutation-edit">
<div class="c-predefined-mutation-edit__container">
<div class="c-predefined-mutation-edit__card">
<div class="c-predefined-mutation-edit__body">
<h3 class="c-predefined-mutation-edit__title">
Edit {{ predefined_mutation.name }}
</h3>
<form @submit.prevent="submit" class="c-predefined-mutation-edit__form">
<div class="c-predefined-mutation-edit__field">
<label for="name" class="c-predefined-mutation-edit__label">Name</label>
<input v-model="form.name" id="name" type="text" class="c-predefined-mutation-edit__input" />
</div>
<div class="c-predefined-mutation-edit__field">
<label for="description" class="c-predefined-mutation-edit__label">Description</label>
<textarea v-model="form.description" id="description" rows="4" class="c-predefined-mutation-edit__textarea"></textarea>
</div>
<div class="c-predefined-mutation-edit__field">
<label for="amount" class="c-predefined-mutation-edit__label">Amount</label>
<input v-model="form.amount" id="amount" type="number" class="c-predefined-mutation-edit__input" />
</div>
<div class="c-predefined-mutation-edit__field">
<label for="type" class="c-predefined-mutation-edit__label">Type</label>
<select v-model="form.type" id="type" class="c-predefined-mutation-edit__select">
<option value="reward">Reward</option>
<option value="penalty">Penalty</option>
</select>
</div>
<div class="c-predefined-mutation-edit__actions">
<button type="submit" :disabled="form.processing" class="c-predefined-mutation-edit__submit-btn">
Save
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<style scoped>
@reference "../../../../css/app.css";
.c-predefined-mutation-edit {
@apply py-12;
}
.c-predefined-mutation-edit__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-predefined-mutation-edit__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-predefined-mutation-edit__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-predefined-mutation-edit__title {
@apply text-lg font-medium;
}
.c-predefined-mutation-edit__form {
@apply mt-6 space-y-6;
}
.c-predefined-mutation-edit__field {
@apply block;
}
.c-predefined-mutation-edit__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-predefined-mutation-edit__input {
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-predefined-mutation-edit__textarea {
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-predefined-mutation-edit__select {
@apply mt-1 block w-full rounded-md border-gray-300 bg-white p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-predefined-mutation-edit__actions {
@apply flex items-center gap-4;
}
.c-predefined-mutation-edit__submit-btn {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
</style>

View File

@ -0,0 +1,266 @@
<script setup lang="ts">
import { Head, useForm, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
predefined_mutations: Array<{
id: number;
name: string;
description: string;
amount: number;
type: string;
}>;
}>();
const form = useForm({
name: '',
description: '',
amount: 0,
type: 'reward',
});
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
name: 'Predefined Mutations',
href: route('dynamics.predefined-mutations.index', props.dynamic.id),
},
];
function submit() {
form.post(route('dynamics.predefined-mutations.store', props.dynamic.id), {
onSuccess: () => form.reset(),
});
}
</script>
<template>
<Head title="Predefined Mutations" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="c-predefined-mutations">
<div class="c-predefined-mutations__container">
<div class="c-predefined-mutations__card">
<div class="c-predefined-mutations__body">
<h3 class="c-predefined-mutations__title">
Predefined Mutations for {{ dynamic.name }}
</h3>
<div class="c-predefined-mutations__list">
<div
v-for="mutation in predefined_mutations"
:key="mutation.id"
class="c-predefined-mutations__item"
>
<div class="c-predefined-mutations__item-details">
<h4 class="c-predefined-mutations__item-name">
{{ mutation.name }}
</h4>
<p class="c-predefined-mutations__item-description">
{{ mutation.description }}
</p>
</div>
<div class="c-predefined-mutations__item-amount">
{{ mutation.amount }}
</div>
<div class="c-predefined-mutations__item-actions">
<Link :href="route('dynamics.predefined-mutations.edit', [dynamic.id, mutation.id])" class="c-predefined-mutations__item-action-btn">
Edit
</Link>
<Link :href="route('dynamics.predefined-mutations.destroy', [dynamic.id, mutation.id])" method="delete" as="button" class="c-predefined-mutations__item-action-btn c-predefined-mutations__item-action-btn--danger">
Delete
</Link>
</div>
</div>
</div>
</div>
</div>
<div class="c-predefined-mutations__card mt-8">
<div class="c-predefined-mutations__body">
<h3 class="c-predefined-mutations__title">
Create New Predefined Mutation
</h3>
<form
@submit.prevent="submit"
class="c-predefined-mutations__form"
>
<div class="c-predefined-mutations__field">
<label
for="name"
class="c-predefined-mutations__label"
>Name</label
>
<input
v-model="form.name"
id="name"
type="text"
class="c-predefined-mutations__input"
/>
</div>
<div class="c-predefined-mutations__field">
<label
for="description"
class="c-predefined-mutations__label"
>Description</label
>
<textarea
v-model="form.description"
id="description"
rows="4"
class="c-predefined-mutations__textarea"
></textarea>
</div>
<div class="c-predefined-mutations__field">
<label
for="amount"
class="c-predefined-mutations__label"
>Amount</label
>
<input
v-model="form.amount"
id="amount"
type="number"
class="c-predefined-mutations__input"
/>
</div>
<div class="c-predefined-mutations__field">
<label
for="type"
class="c-predefined-mutations__label"
>Type</label
>
<select
v-model="form.type"
id="type"
class="c-predefined-mutations__select"
>
<option value="reward">Reward</option>
<option value="penalty">Penalty</option>
</select>
</div>
<div class="c-predefined-mutations__actions">
<button
type="submit"
:disabled="form.processing"
class="c-predefined-mutations__submit-btn"
>
Create
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<style scoped>
@reference "../../../../css/app.css";
.c-predefined-mutations {
@apply py-12;
}
.c-predefined-mutations__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-predefined-mutations__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-predefined-mutations__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-predefined-mutations__title {
@apply text-lg font-medium;
}
.c-predefined-mutations__list {
@apply mt-6 space-y-4;
}
.c-predefined-mutations__item {
@apply flex items-center justify-between rounded-lg border p-4 dark:border-gray-700;
}
.c-predefined-mutations__item-details {
@apply flex-1;
}
.c-predefined-mutations__item-name {
@apply font-semibold;
}
.c-predefined-mutations__item-description {
@apply text-sm text-gray-600 dark:text-gray-400;
}
.c-predefined-mutations__item-amount {
@apply text-lg font-semibold;
}
.c-predefined-mutations__item-actions {
@apply flex gap-2;
}
.c-predefined-mutations__item-action-btn {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-3 py-1.5 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
.c-predefined-mutations__item-action-btn--danger {
@apply bg-red-600 hover:bg-red-500 focus:bg-red-500 active:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400 dark:focus:bg-red-400 dark:active:bg-red-600;
}
.c-predefined-mutations__form {
@apply mt-6 space-y-6;
}
.c-predefined-mutations__field {
@apply block;
}
.c-predefined-mutations__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-predefined-mutations__input {
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-predefined-mutations__textarea {
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-predefined-mutations__select {
@apply mt-1 block w-full rounded-md border-gray-300 bg-white p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-predefined-mutations__actions {
@apply flex items-center gap-4;
}
.c-predefined-mutations__submit-btn {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
</style>

View File

@ -0,0 +1,145 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
ledger: {
id: number;
name: string;
rules: string;
};
}>();
const form = useForm({
name: props.ledger.name,
rules: props.ledger.rules,
});
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
name: props.ledger.name,
href: route('dynamics.ledgers.show', [props.dynamic.id, props.ledger.id]),
},
{
name: 'Edit',
href: route('dynamics.ledgers.edit', [props.dynamic.id, props.ledger.id]),
},
];
function submit() {
form.put(route('dynamics.ledgers.update', [props.dynamic.id, props.ledger.id]));
}
</script>
<template>
<Head title="Edit Ledger" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="c-ledger-edit">
<div class="c-ledger-edit__container">
<div class="c-ledger-edit__card">
<div class="c-ledger-edit__body">
<h3 class="c-ledger-edit__title">
Edit {{ ledger.name }}
</h3>
<form @submit.prevent="submit" class="c-ledger-edit__form">
<div class="c-ledger-edit__field">
<label for="name" class="c-ledger-edit__label">Name</label>
<input v-model="form.name" id="name" type="text" class="c-ledger-edit__input" />
</div>
<div class="c-ledger-edit__field">
<label for="rules" class="c-ledger-edit__label">Rules</label>
<textarea v-model="form.rules" id="rules" rows="4" class="c-ledger-edit__textarea"></textarea>
</div>
<div class="c-ledger-edit__actions">
<button type="submit" :disabled="form.processing" class="c-ledger-edit__submit-btn">
Save
</button>
<Link
:href="route('dynamics.ledgers.close', [dynamic.id, ledger.id])"
method="put"
as="button"
class="c-ledger-edit__submit-btn c-ledger-edit__submit-btn--danger"
>
Close Ledger
</Link>
</div>
</form>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<style scoped>
@reference "../../../css/app.css";
.c-ledger-edit {
@apply py-12;
}
.c-ledger-edit__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-ledger-edit__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-ledger-edit__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-ledger-edit__title {
@apply text-lg font-medium;
}
.c-ledger-edit__form {
@apply mt-6 space-y-6;
}
.c-ledger-edit__field {
@apply block;
}
.c-ledger-edit__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-ledger-edit__input {
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-ledger-edit__textarea {
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-ledger-edit__actions {
@apply flex items-center gap-4;
}
.c-ledger-edit__submit-btn {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
.c-ledger-edit__submit-btn--danger {
@apply bg-red-600 hover:bg-red-500 focus:bg-red-500 active:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400 dark:focus:bg-red-400 dark:active:bg-red-600;
}
</style>

View File

@ -212,6 +212,12 @@ function isOwnerUser(userId: number): boolean {
> >
Predefined Mutations Predefined Mutations
</InertiaLink> </InertiaLink>
<InertiaLink
:href="route('dynamics.ledgers.edit', [dynamic.id, ledger.id])"
class="c-ledger-show__manage-btn"
>
Edit Ledger
</InertiaLink>
</div> </div>
</div> </div>

View File

@ -19,10 +19,12 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/dynamics/{dynamic}/ledgers/create', [LedgerController::class, 'create'])->name('dynamics.ledgers.create'); Route::get('/dynamics/{dynamic}/ledgers/create', [LedgerController::class, 'create'])->name('dynamics.ledgers.create');
Route::get('/dynamics/{dynamic}/ledgers/{ledger}/messages', [LedgerController::class, 'messages'])->name('dynamics.ledgers.messages'); Route::get('/dynamics/{dynamic}/ledgers/{ledger}/messages', [LedgerController::class, 'messages'])->name('dynamics.ledgers.messages');
Route::put('/dynamics/{dynamic}/ledgers/{ledger}/close', [LedgerController::class, 'close'])->name('dynamics.ledgers.close');
Route::resource('dynamics.ledgers', LedgerController::class)->scoped()->except(['create']); Route::resource('dynamics.ledgers', LedgerController::class)->scoped()->except(['create']);
Route::resource('dynamics.ledgers.predefined-mutations', \App\Http\Controllers\PredefinedMutationController::class)->scoped(); Route::resource('dynamics.predefined-mutations', \App\Http\Controllers\PredefinedMutationController::class)->scoped();
Route::put('/dynamics/{dynamic}/ledgers/{ledger}/mutations/{mutation}/void', [\App\Http\Controllers\MutationController::class, 'void'])->name('dynamics.ledgers.mutations.void');
Route::resource('dynamics.ledgers.mutations', MutationController::class)->scoped(); Route::resource('dynamics.ledgers.mutations', MutationController::class)->scoped();
Route::get('/dynamics/{dynamic}/invitations/create', [\App\Http\Controllers\DynamicInvitationController::class, 'create'])->name('dynamics.invitations.create'); Route::get('/dynamics/{dynamic}/invitations/create', [\App\Http\Controllers\DynamicInvitationController::class, 'create'])->name('dynamics.invitations.create');

View File

@ -19,7 +19,7 @@ test('authenticated users can visit the dashboard', function () {
$response = $this->get(route('dashboard')); $response = $this->get(route('dashboard'));
$response->assertOk(); $response->assertOk();
$response->assertInertia(fn ($page) => $page->component('Dashboard')->has('unreadEntities')); $response->assertInertia(fn ($page) => $page->component('Dashboard')->has('unreadDynamics'));
}); });
test('visiting dynamic updates the read cursor', function () { test('visiting dynamic updates the read cursor', function () {
@ -34,7 +34,7 @@ test('visiting dynamic updates the read cursor', function () {
expect($initialCursor->toIso8601String())->toBe('1970-01-01T00:00:00+00:00'); expect($initialCursor->toIso8601String())->toBe('1970-01-01T00:00:00+00:00');
// Visit Dynamic Show // Visit Dynamic Show
$this->get(route('dynamics.show', $dynamic->id))->assertOk(); $this->get(route('dynamics.show', $dynamic->uuid))->assertOk();
// Re-check cursor is updated to near now // Re-check cursor is updated to near now
$updatedCursor = $service->getCursorReadAt($user, $dynamic); $updatedCursor = $service->getCursorReadAt($user, $dynamic);
@ -56,7 +56,7 @@ test('visiting ledger updates the read cursor', function () {
expect($initialCursor->toIso8601String())->toBe('1970-01-01T00:00:00+00:00'); expect($initialCursor->toIso8601String())->toBe('1970-01-01T00:00:00+00:00');
// Visit Ledger Show // Visit Ledger Show
$this->get(route('dynamics.ledgers.show', [$dynamic->id, $ledger->id]))->assertOk(); $this->get(route('dynamics.ledgers.show', [$dynamic->uuid, $ledger->uuid]))->assertOk();
// Re-check cursor is updated to near now // Re-check cursor is updated to near now
$updatedCursor = $service->getCursorReadAt($user, $ledger); $updatedCursor = $service->getCursorReadAt($user, $ledger);
@ -101,23 +101,23 @@ test('dashboard groups and filters unread entities correctly based on cursor', f
// Verify unread grouping structure // Verify unread grouping structure
$response->assertInertia(fn ($page) => $page $response->assertInertia(fn ($page) => $page
->component('Dashboard') ->component('Dashboard')
->where('unreadEntities.0.name', 'Testing Dynamic') ->where('unreadDynamics.0.name', 'Testing Dynamic')
->where('unreadEntities.0.unread_count', 1) ->where('unreadDynamics.0.unread_count', 1)
->has('unreadEntities.0.context_activities', 1) // Should have old message as context ->has('unreadDynamics.0.context_activities', 1) // Should have old message as context
->where('unreadEntities.0.context_activities.0.content', 'Old message context') ->where('unreadDynamics.0.context_activities.0.content', 'Old message context')
->has('unreadEntities.0.new_activities', 1) // Should have unread message ->has('unreadDynamics.0.new_activities', 1) // Should have unread message
->where('unreadEntities.0.new_activities.0.content', 'New unread message alert') ->where('unreadDynamics.0.new_activities.0.content', 'New unread message alert')
); );
// Now visit the Dynamic, which clears the unread count // Now visit the Dynamic, which clears the unread count
$this->get(route('dynamics.show', $dynamic->id))->assertOk(); $this->get(route('dynamics.show', $dynamic->uuid))->assertOk();
// Dashboard should now show 0 unread groups (caught up) // Dashboard should now show 0 unread groups (caught up)
$response2 = $this->get(route('dashboard')); $response2 = $this->get(route('dashboard'));
$response2->assertOk(); $response2->assertOk();
$response2->assertInertia(fn ($page) => $page $response2->assertInertia(fn ($page) => $page
->component('Dashboard') ->component('Dashboard')
->has('unreadEntities', 0) ->has('unreadDynamics', 0)
); );
Carbon::setTestNow(); // Reset test time Carbon::setTestNow(); // Reset test time

View File

@ -19,7 +19,7 @@ test('only owners can invite other users to a dynamic', function () {
// 1. Participant tries to send an invite (forbidden) // 1. Participant tries to send an invite (forbidden)
$response = $this->actingAs($participant) $response = $this->actingAs($participant)
->post(route('dynamics.invitations.store', $dynamic), [ ->post(route('dynamics.invitations.store', $dynamic->uuid), [
'email' => 'invitee@example.com', 'email' => 'invitee@example.com',
'role' => 'participant', 'role' => 'participant',
]); ]);
@ -29,7 +29,7 @@ test('only owners can invite other users to a dynamic', function () {
// 2. Owner sends a valid invite (allowed) // 2. Owner sends a valid invite (allowed)
$response = $this->actingAs($owner) $response = $this->actingAs($owner)
->post(route('dynamics.invitations.store', $dynamic), [ ->post(route('dynamics.invitations.store', $dynamic->uuid), [
'email' => 'invitee@example.com', 'email' => 'invitee@example.com',
'role' => 'participant', 'role' => 'participant',
]); ]);
@ -83,7 +83,7 @@ test('only the user with the specified email address can accept the link', funct
// 3. Intended user accepts the signed invitation link (success) // 3. Intended user accepts the signed invitation link (success)
$response = $this->actingAs($invitee)->get($signedUrl); $response = $this->actingAs($invitee)->get($signedUrl);
$response->assertRedirect(route('dynamics.show', $dynamic)); $response->assertRedirect(route('dynamics.show', $dynamic->uuid));
// Verify invitee is joined as a participant with the specified role // Verify invitee is joined as a participant with the specified role
$isJoined = $dynamic->participants() $isJoined = $dynamic->participants()

View File

@ -12,17 +12,17 @@ test('dynamic owners can view ledger creation form and create ledgers', function
$this->actingAs($owner); $this->actingAs($owner);
// Can view form // Can view form
$this->get(route('dynamics.ledgers.create', $dynamic->id))->assertOk(); $this->get(route('dynamics.ledgers.create', $dynamic->uuid))->assertOk();
// Can store ledger // Can store ledger
$response = $this->post(route('dynamics.ledgers.store', $dynamic->id), [ $response = $this->post(route('dynamics.ledgers.store', $dynamic->uuid), [
'name' => 'Chores Ledger', 'name' => 'Chores Ledger',
'rules' => 'Do the tasks.', 'rules' => 'Do the tasks.',
'alignment' => 'positive', 'alignment' => 'positive',
]); ]);
$response->assertSessionHasNoErrors(); $response->assertSessionHasNoErrors();
$response->assertRedirect(route('dynamics.show', $dynamic->id)); $response->assertRedirect(route('dynamics.show', $dynamic->uuid));
$this->assertDatabaseHas('ledgers', [ $this->assertDatabaseHas('ledgers', [
'dynamic_id' => $dynamic->id, 'dynamic_id' => $dynamic->id,
@ -41,10 +41,10 @@ test('non-owners cannot view ledger creation form or store ledgers', function ()
$this->actingAs($participant); $this->actingAs($participant);
// Cannot view form // Cannot view form
$this->get(route('dynamics.ledgers.create', $dynamic->id))->assertStatus(403); $this->get(route('dynamics.ledgers.create', $dynamic->uuid))->assertStatus(403);
// Cannot store ledger // Cannot store ledger
$response = $this->post(route('dynamics.ledgers.store', $dynamic->id), [ $response = $this->post(route('dynamics.ledgers.store', $dynamic->uuid), [
'name' => 'Illegal Ledger', 'name' => 'Illegal Ledger',
'rules' => 'This should fail.', 'rules' => 'This should fail.',
'alignment' => 'positive', 'alignment' => 'positive',

View File

@ -15,7 +15,7 @@ test('authenticated participant can view another participant detail page in dyna
$this->actingAs($owner); $this->actingAs($owner);
$response = $this->get(route('dynamics.users.show', [$dynamic->id, $participant->id])); $response = $this->get(route('dynamics.users.show', [$dynamic->uuid, $participant->uuid]));
$response->assertOk(); $response->assertOk();
$response->assertInertia(fn ($page) => $page $response->assertInertia(fn ($page) => $page
@ -40,7 +40,7 @@ test('non-participant cannot view participant detail page in dynamic', function
$this->actingAs($outsider); $this->actingAs($outsider);
$response = $this->get(route('dynamics.users.show', [$dynamic->id, $participant->id])); $response = $this->get(route('dynamics.users.show', [$dynamic->uuid, $participant->uuid]));
$response->assertStatus(403); $response->assertStatus(403);
}); });
@ -82,7 +82,7 @@ test('participant detail page displays their recent mutations in dynamic', funct
$this->actingAs($owner); $this->actingAs($owner);
$response = $this->get(route('dynamics.users.show', [$dynamic->id, $participant->id])); $response = $this->get(route('dynamics.users.show', [$dynamic->uuid, $participant->uuid]));
$response->assertOk(); $response->assertOk();
$response->assertInertia(fn ($page) => $page $response->assertInertia(fn ($page) => $page

View File

@ -29,7 +29,7 @@ test('participant can update their display name', function () {
$this->actingAs($user); $this->actingAs($user);
$response = $this->put(route('dynamics.participant.update', $dynamic->id), [ $response = $this->put(route('dynamics.participant.update', $dynamic->uuid), [
'display_name' => 'Ally', 'display_name' => 'Ally',
]); ]);
@ -53,7 +53,7 @@ test('display name update requires display_name parameter', function () {
$this->actingAs($user); $this->actingAs($user);
$response = $this->put(route('dynamics.participant.update', $dynamic->id), [ $response = $this->put(route('dynamics.participant.update', $dynamic->uuid), [
'display_name' => '', 'display_name' => '',
]); ]);
@ -68,7 +68,7 @@ test('non participant cannot update display name', function () {
$this->actingAs($nonParticipant); $this->actingAs($nonParticipant);
$response = $this->put(route('dynamics.participant.update', $dynamic->id), [ $response = $this->put(route('dynamics.participant.update', $dynamic->uuid), [
'display_name' => 'Bobby', 'display_name' => 'Bobby',
]); ]);

View File

@ -5,14 +5,13 @@ use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\PredefinedMutation; use App\Models\PredefinedMutation;
test('owner can view predefined mutations for ledger', function () { test('owner can view predefined mutations for dynamic', function () {
$owner = User::factory()->create(); $owner = User::factory()->create();
$dynamic = Dynamic::factory()->create(); $dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']); $dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$predefined = PredefinedMutation::create([ $predefined = PredefinedMutation::create([
'ledger_id' => $ledger->id, 'dynamic_id' => $dynamic->id,
'name' => 'Weekly Room Cleaning', 'name' => 'Weekly Room Cleaning',
'description' => 'Cleaned up the master bedroom', 'description' => 'Cleaned up the master bedroom',
'amount' => 20, 'amount' => 20,
@ -20,73 +19,71 @@ test('owner can view predefined mutations for ledger', function () {
$this->actingAs($owner); $this->actingAs($owner);
$response = $this->get(route('dynamics.ledgers.predefined-mutations.index', [$dynamic->id, $ledger->id])); $response = $this->get(route('dynamics.predefined-mutations.index', $dynamic->uuid));
$response->assertOk(); $response->assertOk();
$response->assertInertia(fn ($page) => $page $response->assertInertia(fn ($page) => $page
->component('Ledgers/PredefinedMutations/Index') ->component('Dynamics/PredefinedMutations/Index')
->where('ledger.id', $ledger->id)
->has('predefined_mutations', 1) ->has('predefined_mutations', 1)
->where('predefined_mutations.0.name', 'Weekly Room Cleaning') ->where('predefined_mutations.0.name', 'Weekly Room Cleaning')
); );
}); });
test('non-owner cannot view predefined mutations for ledger', function () { test('non-owner cannot view predefined mutations for dynamic', function () {
$owner = User::factory()->create(); $owner = User::factory()->create();
$participant = User::factory()->create(); $participant = User::factory()->create();
$dynamic = Dynamic::factory()->create(); $dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']); $dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']); $dynamic->participants()->attach($participant->id, ['role' => 'participant']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($participant); $this->actingAs($participant);
$response = $this->get(route('dynamics.ledgers.predefined-mutations.index', [$dynamic->id, $ledger->id])); $response = $this->get(route('dynamics.predefined-mutations.index', $dynamic->uuid));
$response->assertStatus(403); $response->assertStatus(403);
}); });
test('owner can create predefined mutations for ledger', function () { test('owner can create predefined mutations for dynamic', function () {
$owner = User::factory()->create(); $owner = User::factory()->create();
$dynamic = Dynamic::factory()->create(); $dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']); $dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($owner); $this->actingAs($owner);
$response = $this->post(route('dynamics.ledgers.predefined-mutations.store', [$dynamic->id, $ledger->id]), [ $response = $this->post(route('dynamics.predefined-mutations.store', $dynamic->uuid), [
'name' => 'Polished mirrors', 'name' => 'Polished mirrors',
'description' => 'Mirror polishing in dungeon', 'description' => 'Mirror polishing in dungeon',
'amount' => 15, 'amount' => 15,
'type' => 'reward',
]); ]);
$response->assertRedirect(); $response->assertRedirect();
$this->assertDatabaseHas('predefined_mutations', [ $this->assertDatabaseHas('predefined_mutations', [
'ledger_id' => $ledger->id, 'dynamic_id' => $dynamic->id,
'name' => 'Polished mirrors', 'name' => 'Polished mirrors',
'amount' => 15, 'amount' => 15,
]); ]);
}); });
test('non-owner cannot create predefined mutations for ledger', function () { test('non-owner cannot create predefined mutations for dynamic', function () {
$owner = User::factory()->create(); $owner = User::factory()->create();
$participant = User::factory()->create(); $participant = User::factory()->create();
$dynamic = Dynamic::factory()->create(); $dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']); $dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']); $dynamic->participants()->attach($participant->id, ['role' => 'participant']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($participant); $this->actingAs($participant);
$response = $this->post(route('dynamics.ledgers.predefined-mutations.store', [$dynamic->id, $ledger->id]), [ $response = $this->post(route('dynamics.predefined-mutations.store', $dynamic->uuid), [
'name' => 'Polished mirrors', 'name' => 'Polished mirrors',
'description' => 'Mirror polishing in dungeon', 'description' => 'Mirror polishing in dungeon',
'amount' => 15, 'amount' => 15,
'type' => 'reward',
]); ]);
$response->assertStatus(403); $response->assertStatus(403);
$this->assertDatabaseMissing('predefined_mutations', [ $this->assertDatabaseMissing('predefined_mutations', [
'ledger_id' => $ledger->id, 'dynamic_id' => $dynamic->id,
'name' => 'Polished mirrors', 'name' => 'Polished mirrors',
]); ]);
}); });