Author SHA1 Message Date
Daan Meijer 06e5600447 ui improvements
linter / quality (push) Failing after 1m8s
tests / ci (8.3) (push) Failing after 52s
tests / ci (8.4) (push) Failing after 1m9s
tests / ci (8.5) (push) Failing after 1m9s
2026-06-17 23:07:59 +02:00
Daan Meijer ed16d5dcda feat: Implement chat pagination, participant single page with mutations, and fix user link substitutions 2026-06-17 22:55:11 +02:00
Daan Meijer 11df4ef55c further development of the predefinedmutations
linter / quality (push) Failing after 1m16s
tests / ci (8.3) (push) Failing after 59s
tests / ci (8.4) (push) Failing after 1m14s
tests / ci (8.5) (push) Failing after 1m15s
2026-06-17 13:30:55 +02:00
Daan Meijer ed23bb2a78 docs: Document ActivityService and system message architecture in AGENTS.md and DECISIONS.md 2026-06-17 11:06:59 +02:00
Daan Meijer ec8cbff770 refactor: Use ActivityService to create system messages in seeder 2026-06-17 11:06:59 +02:00
Daan Meijer c6d482e3de better system messages, display name relocation
linter / quality (push) Failing after 1m6s
tests / ci (8.3) (push) Failing after 54s
tests / ci (8.4) (push) Failing after 1m8s
tests / ci (8.5) (push) Failing after 1m13s
2026-06-17 11:00:35 +02:00
Daan Meijer 0fee3c1972 different names in different dynamics
linter / quality (push) Failing after 1m9s
tests / ci (8.3) (push) Failing after 51s
tests / ci (8.4) (push) Failing after 1m7s
tests / ci (8.5) (push) Failing after 1m9s
2026-06-17 09:38:54 +02:00
39 changed files with 1495 additions and 189 deletions
+9 -6
View File
@@ -52,12 +52,7 @@ class DynamicController extends Controller
$activityService->updateCursor($request->user(), $dynamic); $activityService->updateCursor($request->user(), $dynamic);
$dynamic->load([ $dynamic->load(['ledgers.media', 'participants', 'chat']);
'ledgers.media',
'participants',
'chat.messages.user',
'chat.messages.media'
]);
$isOwner = $dynamic->participants() $isOwner = $dynamic->participants()
->where('user_id', $request->user()->id) ->where('user_id', $request->user()->id)
@@ -67,9 +62,17 @@ class DynamicController extends Controller
return Inertia::render('Dynamics/Show', [ return Inertia::render('Dynamics/Show', [
'dynamic' => $dynamic, 'dynamic' => $dynamic,
'isOwner' => $isOwner, 'isOwner' => $isOwner,
'messages' => $dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20),
]); ]);
} }
public function messages(Request $request, Dynamic $dynamic)
{
$this->authorize('view', $dynamic);
return $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.
*/ */
@@ -116,7 +116,7 @@ class DynamicInvitationController extends Controller
// Log to Dynamic chat activity log! // Log to Dynamic chat activity log!
$dynamic->chat->messages()->create([ $dynamic->chat->messages()->create([
'user_id' => null, 'user_id' => null,
'content' => "{$request->user()->name} joined the Dynamic as a " . strtoupper($invitation->role), 'content' => "<user:{$request->user()->id}> joined the Dynamic as a " . strtoupper($invitation->role),
'subject_id' => $request->user()->id, 'subject_id' => $request->user()->id,
'subject_type' => \App\Models\User::class, 'subject_type' => \App\Models\User::class,
]); ]);
+9 -2
View File
@@ -73,8 +73,7 @@ class LedgerController extends Controller
}, },
'mutations.user', 'mutations.user',
'mutations.media', 'mutations.media',
'mutations.chat.messages.user', 'mutations.chat',
'mutations.chat.messages.media'
]); ]);
$isOwner = $dynamic->participants() $isOwner = $dynamic->participants()
@@ -86,9 +85,17 @@ class LedgerController extends Controller
'dynamic' => $dynamic, 'dynamic' => $dynamic,
'ledger' => $ledger, 'ledger' => $ledger,
'isOwner' => $isOwner, 'isOwner' => $isOwner,
'messages' => $dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20),
]); ]);
} }
public function messages(Request $request, Dynamic $dynamic, Ledger $ledger)
{
$this->authorize('view', $ledger);
return $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.
*/ */
+12 -30
View File
@@ -67,30 +67,6 @@ class MutationController extends Controller
return $mutation; return $mutation;
}); });
// Log to Mutation and Dynamic chats
$user = $request->user();
$mutationMsg = $mutation->chat->messages()->create([
'user_id' => $user->id,
'content' => $status === 'approved'
? "System: Entry was created by {$user->name}."
: "System: Suggestion was created by {$user->name}.",
]);
broadcast(new \App\Events\MessageSent($mutationMsg));
if ($status === 'approved') {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id,
'content' => "System: {$user->name} added entry \"{$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} suggested \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.",
]);
}
broadcast(new \App\Events\MessageSent($dynamicMsg));
// Broadcast the real-time creation event! // Broadcast the real-time creation event!
broadcast(new \App\Events\MutationCreated($mutation)); broadcast(new \App\Events\MutationCreated($mutation));
@@ -151,20 +127,26 @@ class MutationController extends Controller
$statusText = strtoupper($newStatus); $statusText = strtoupper($newStatus);
$mutationMsg = $mutation->chat->messages()->create([ $mutationMsg = $mutation->chat->messages()->create([
'user_id' => $user->id, 'user_id' => null,
'content' => "System: Suggestion was {$statusText} by {$user->name}.", 'content' => "Suggestion was {$statusText} by <user:{$user->id}>.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]); ]);
broadcast(new \App\Events\MessageSent($mutationMsg)); broadcast(new \App\Events\MessageSent($mutationMsg));
if ($newStatus === 'approved') { if ($newStatus === 'approved') {
$dynamicMsg = $dynamic->chat->messages()->create([ $dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id, 'user_id' => null,
'content' => "System: {$user->name} APPROVED the suggestion \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.", 'content' => "<user:{$user->id}> APPROVED the suggestion \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]); ]);
} else { } else {
$dynamicMsg = $dynamic->chat->messages()->create([ $dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id, 'user_id' => null,
'content' => "System: {$user->name} REJECTED the suggestion \"{$mutation->description}\" on \"{$ledger->name}\" ledger.", 'content' => "<user:{$user->id}> REJECTED the suggestion \"{$mutation->description}\" on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]); ]);
} }
broadcast(new \App\Events\MessageSent($dynamicMsg)); broadcast(new \App\Events\MessageSent($dynamicMsg));
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Models\Dynamic;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ParticipantController extends Controller
{
use AuthorizesRequests;
public function update(Request $request, Dynamic $dynamic)
{
$request->validate([
'display_name' => ['required', 'string', 'max:255'],
]);
$participant = $dynamic->participants()->where('user_id', $request->user()->id)->firstOrFail();
$dynamic->participants()->updateExistingPivot($participant->id, [
'display_name' => $request->input('display_name'),
]);
return redirect()->back()->with('success', 'Display name updated successfully!');
}
public function show(Request $request, Dynamic $dynamic, User $user)
{
// Ensure both the authenticated user and the target user are in the dynamic
if (!$dynamic->participants()->where('user_id', $request->user()->id)->exists()) {
abort(403);
}
$participant = $dynamic->participants()->where('user_id', $user->id)->firstOrFail();
$mutations = $user->mutations()
->whereHas('ledger', fn($query) => $query->where('dynamic_id', $dynamic->id))
->with('ledger')
->latest('id')
->take(10)
->get();
return Inertia::render('Dynamics/Participants/Show', [
'dynamic' => $dynamic,
'participant' => [
'id' => $user->id,
'name' => $user->name,
'display_name' => $participant->pivot->display_name,
'role' => $participant->pivot->role,
],
'mutations' => $mutations,
]);
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\PredefinedMutation; use App\Models\PredefinedMutation;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -15,20 +16,21 @@ class PredefinedMutationController extends Controller
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
public function index(Dynamic $dynamic) public function index(Dynamic $dynamic, Ledger $ledger)
{ {
$this->authorize('update', $dynamic); $this->authorize('update', $dynamic);
return Inertia::render('Dynamics/PredefinedMutations/Index', [ return Inertia::render('Ledgers/PredefinedMutations/Index', [
'dynamic' => $dynamic, 'dynamic' => $dynamic,
'predefined_mutations' => $dynamic->predefinedMutations()->latest()->get(), 'ledger' => $ledger,
'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) public function store(Request $request, Dynamic $dynamic, Ledger $ledger)
{ {
$this->authorize('update', $dynamic); $this->authorize('update', $dynamic);
@@ -36,11 +38,10 @@ 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'],
]); ]);
$dynamic->predefinedMutations()->create($request->all()); $ledger->predefinedMutations()->create($request->all());
return redirect()->route('dynamics.predefined-mutations.index', $dynamic); return redirect()->route('dynamics.ledgers.predefined-mutations.index', [$dynamic, $ledger]);
} }
} }
@@ -19,9 +19,14 @@ class ProfileController extends Controller
*/ */
public function edit(Request $request): Response public function edit(Request $request): Response
{ {
$dynamics = $request->user()->dynamics()
->withPivot('display_name')
->get();
return Inertia::render('settings/Profile', [ return Inertia::render('settings/Profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail, 'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'), 'status' => $request->session()->get('status'),
'dynamics' => $dynamics,
]); ]);
} }
+1 -1
View File
@@ -21,7 +21,7 @@ class Dynamic extends Model
public function participants(): BelongsToMany public function participants(): BelongsToMany
{ {
return $this->belongsToMany(User::class, 'participants')->withPivot('role'); return $this->belongsToMany(User::class, 'participants')->withPivot('role', 'display_name');
} }
public function ledgers(): HasMany public function ledgers(): HasMany
+5
View File
@@ -31,6 +31,11 @@ class Ledger extends Model
return $this->hasMany(Mutation::class); return $this->hasMany(Mutation::class);
} }
public function predefinedMutations(): HasMany
{
return $this->hasMany(PredefinedMutation::class);
}
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{ {
return $this->morphMany(Media::class, 'mediable'); return $this->morphMany(Media::class, 'mediable');
+33
View File
@@ -52,6 +52,39 @@ class Mutation extends Model
{ {
static::created(function (Mutation $mutation) { static::created(function (Mutation $mutation) {
$mutation->chat()->create([]); $mutation->chat()->create([]);
// Create system messages automatically!
$user = $mutation->user;
$ledger = $mutation->ledger;
$dynamic = $ledger->dynamic;
$status = $mutation->status;
$mutationMsg = $mutation->chat->messages()->create([
'user_id' => null,
'content' => $status === 'approved'
? "Entry was created by <user:{$user->id}>."
: "Suggestion was created by <user:{$user->id}>.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]);
broadcast(new \App\Events\MessageSent($mutationMsg));
if ($status === 'approved') {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => null,
'content' => "<user:{$user->id}> added entry \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]);
} else {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => null,
'content' => "<user:{$user->id}> suggested \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]);
}
broadcast(new \App\Events\MessageSent($dynamicMsg));
}); });
} }
} }
+1
View File
@@ -12,5 +12,6 @@ class Participant extends Pivot
'user_id', 'user_id',
'dynamic_id', 'dynamic_id',
'role', 'role',
'display_name',
]; ];
} }
+3 -4
View File
@@ -11,15 +11,14 @@ class PredefinedMutation extends Model
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'dynamic_id', 'ledger_id',
'name', 'name',
'description', 'description',
'amount', 'amount',
'type',
]; ];
public function dynamic(): BelongsTo public function ledger(): BelongsTo
{ {
return $this->belongsTo(Dynamic::class); return $this->belongsTo(Ledger::class);
} }
} }
+7
View File
@@ -49,6 +49,13 @@ class User extends Authenticatable implements PasskeyUser
return $this->hasMany(ReadCursor::class); return $this->hasMany(ReadCursor::class);
} }
public function displayNameFor(Dynamic $dynamic): string
{
$participant = $dynamic->participants()->where('user_id', $this->id)->first();
return $participant?->pivot?->display_name ?? $this->name;
}
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.
* *
+18 -3
View File
@@ -77,20 +77,35 @@ class ActivityService
{ {
if ($entity instanceof Dynamic) { if ($entity instanceof Dynamic) {
$chatId = $entity->chat->id; $chatId = $entity->chat->id;
$dynamic = $entity;
} elseif ($entity instanceof Ledger) { } elseif ($entity instanceof Ledger) {
$chatId = $entity->dynamic->chat->id; $dynamic = $entity->dynamic;
$chatId = $dynamic->chat->id;
} else { } else {
return []; return [];
} }
$participants = $dynamic->participants()->withPivot('display_name')->get();
$participantsMap = $participants->reduce(function ($acc, $p) {
$acc[$p->id] = $p->pivot->display_name ?? $p->name;
return $acc;
}, []);
$messages = Message::where('chat_id', $chatId) $messages = Message::where('chat_id', $chatId)
->with(['user', 'subject']) ->with(['user', 'subject'])
->latest() ->latest()
->get(); ->get();
return $messages->map(function ($message) { return $messages->map(function ($message) use ($participantsMap) {
$messageData = $message->toArray(); $messageData = $message->toArray();
$messageData['url'] = $this->getUrlForMessage($message); $messageData['url'] = $this->getUrlForMessage($message);
// Resolve <user:id> placeholders to actual names/display names
$messageData['content'] = preg_replace_callback('/<user:(\d+)>/', function ($matches) use ($participantsMap) {
$userId = $matches[1];
return $participantsMap[$userId] ?? "User #{$userId}";
}, $message->content);
return $messageData; return $messageData;
})->all(); })->all();
} }
@@ -141,7 +156,7 @@ class ActivityService
'url' => $url, 'url' => $url,
'unread_count' => count($unread), 'unread_count' => count($unread),
'context_activities' => $context, 'context_activities' => $context,
'new_activities' => $unread, 'new_activities' => array_reverse($unread),
]; ];
} }
} }
@@ -13,11 +13,10 @@ 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('dynamic_id')->constrained()->cascadeOnDelete(); $table->foreignId('ledger_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();
}); });
} }
@@ -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('participants', function (Blueprint $table) {
$table->string('display_name')->nullable()->after('role');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('participants', function (Blueprint $table) {
$table->dropColumn('display_name');
});
}
};
+201 -36
View File
@@ -7,7 +7,6 @@ use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation; use App\Models\Mutation;
use App\Models\Message; use App\Models\Message;
use App\Services\ActivityService;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -17,9 +16,9 @@ class DatabaseSeeder extends Seeder
/** /**
* Seed the application's database. * Seed the application's database.
*/ */
public function run(ActivityService $activityService): void public function run(): void
{ {
DB::transaction(function () use ($activityService) { DB::transaction(function () {
// 1. Create Core Users // 1. Create Core Users
$testUser = User::factory()->create([ $testUser = User::factory()->create([
'name' => 'Test User', 'name' => 'Test User',
@@ -53,24 +52,41 @@ class DatabaseSeeder extends Seeder
// ---------------------------------------------------- // ----------------------------------------------------
$velvetSanctuary = Dynamic::create([ $velvetSanctuary = Dynamic::create([
'name' => 'The Velvet Sanctuary', 'name' => 'The Velvet Sanctuary',
'rules' => "1. Respect limits and boundaries at all times. 'rules' => "1. Respect limits and boundaries at all times.\n2. Submit daily logs for curfew and chores.\n3. Maintain proper protocol in the general discussion.",
2. Submit daily logs for curfew and chores.
3. Maintain proper protocol in the general discussion.",
]); ]);
// Add participants (Test User is owner, Alice is owner, Bob is submissive/participant) // Add participants (Test User is owner, Alice is owner, Bob is submissive/participant)
$velvetSanctuary->participants()->attach($testUser->id, ['role' => 'owner']); $velvetSanctuary->participants()->attach($testUser->id, ['role' => 'owner', 'display_name' => 'The Master']);
$velvetSanctuary->participants()->attach($alice->id, ['role' => 'owner']); $velvetSanctuary->participants()->attach($alice->id, ['role' => 'owner']);
$velvetSanctuary->participants()->attach($bob->id, ['role' => 'participant']); $velvetSanctuary->participants()->attach($bob->id, ['role' => 'participant', 'display_name' => 'Bitch Boi']);
// Chat has been auto-created by the booted hook on Dynamic // Chat has been auto-created by the booted hook on Dynamic
$velvetChat = $velvetSanctuary->chat; $velvetChat = $velvetSanctuary->chat;
// Seed Dynamic Chat Messages // Seed Dynamic Chat Messages
$activityService->createMessage($velvetChat, $alice, 'Good morning everyone. Bob, please ensure the Obsidian room is polished before 4 PM.'); Message::create([
$activityService->createMessage($velvetChat, $testUser, 'I will review the curfew log later today.'); 'chat_id' => $velvetChat->id,
$activityService->createMessage($velvetChat, $bob, "Yes, Sir. Yes, Ma'am. I am starting on the chores now."); 'user_id' => $alice->id,
$activityService->createMessage($velvetChat, $testUser, 'Excellent. Keep up the high standard.'); 'content' => 'Good morning everyone. Bob, please ensure the Obsidian room is polished before 4 PM.',
]);
Message::create([
'chat_id' => $velvetChat->id,
'user_id' => $testUser->id,
'content' => 'I will review the curfew log later today.',
]);
Message::create([
'chat_id' => $velvetChat->id,
'user_id' => $bob->id,
'content' => "Yes, Sir. Yes, Ma'am. I am starting on the chores now.",
]);
Message::create([
'chat_id' => $velvetChat->id,
'user_id' => $testUser->id,
'content' => 'Excellent. Keep up the high standard.',
]);
// Add Ledgers // Add Ledgers
$curfewLedger = Ledger::create([ $curfewLedger = Ledger::create([
@@ -98,37 +114,145 @@ class DatabaseSeeder extends Seeder
]); ]);
// Seed Curfew Mutations // Seed Curfew Mutations
$activityService->createMutation($curfewLedger, $bob, 'reward', 10, 'Checked in by 10:45 PM on Friday', 'approved'); Mutation::create([
$activityService->createMutation($curfewLedger, $bob, 'reward', 15, 'Checked in by 10:30 PM on Saturday', 'approved'); 'ledger_id' => $curfewLedger->id,
$activityService->createMutation($curfewLedger, $bob, 'reward', 10, 'Checked in by 10:50 PM on Sunday', 'approved'); 'user_id' => $bob->id,
'type' => 'reward',
'amount' => 10,
'description' => 'Checked in by 10:45 PM on Friday',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $curfewLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 15,
'description' => 'Checked in by 10:30 PM on Saturday',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $curfewLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 10,
'description' => 'Checked in by 10:50 PM on Sunday',
'status' => 'approved',
]);
// Seed Cleaning Mutations // Seed Cleaning Mutations
$activityService->createMutation($cleaningLedger, $bob, 'reward', 15, 'Deep cleaning of the main chamber', 'approved'); Mutation::create([
$activityService->createMutation($cleaningLedger, $bob, 'reward', 20, 'Arranged gear rack and polished leather accessories', 'approved'); 'ledger_id' => $cleaningLedger->id,
$activityService->createMutation($cleaningLedger, $bob, 'reward', 10, 'Mopped obsidian floors', 'approved'); 'user_id' => $bob->id,
$activityService->createMutation($cleaningLedger, $alice, 'penalty', -10, 'Left keys in the locks unmonitored', 'approved'); 'type' => 'reward',
'amount' => 15,
'description' => 'Deep cleaning of the main chamber',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 20,
'description' => 'Arranged gear rack and polished leather accessories',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 10,
'description' => 'Mopped obsidian floors',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $cleaningLedger->id,
'user_id' => $alice->id,
'type' => 'penalty',
'amount' => -10,
'description' => 'Left keys in the locks unmonitored',
'status' => 'approved',
]);
// Seed Pending Mutation with its own Chat Messages! // Seed Pending Mutation with its own Chat Messages!
$pendingMutation = $activityService->createMutation($cleaningLedger, $bob, 'addition', 10, 'Weekly chore submission - dusting shelves', 'pending'); $pendingMutation = Mutation::create([
'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id,
'type' => 'addition',
'amount' => 10,
'description' => 'Weekly chore submission - dusting shelves',
'status' => 'pending',
]);
// Pending mutation chat messages (chat is auto-created on booted)
$pendingMutationChat = $pendingMutation->chat; $pendingMutationChat = $pendingMutation->chat;
$activityService->createMessage($pendingMutationChat, $bob, 'I have finished the shelves. Please approve when convenient.'); Message::create([
$activityService->createMessage($pendingMutationChat, $alice, "I checked them; there is still some dust on the top shelf. I'll leave this pending until it's perfect."); 'chat_id' => $pendingMutationChat->id,
$activityService->createMessage($pendingMutationChat, $bob, 'Apologies, Ma\'am. I will re-wipe the top section immediately!'); 'user_id' => $bob->id,
'content' => 'I have finished the shelves. Please approve when convenient.',
]);
Message::create([
'chat_id' => $pendingMutationChat->id,
'user_id' => $alice->id,
'content' => "I checked them; there is still some dust on the top shelf. I'll leave this pending until it's perfect.",
]);
Message::create([
'chat_id' => $pendingMutationChat->id,
'user_id' => $bob->id,
'content' => 'Apologies, Ma\'am. I will re-wipe the top section immediately!',
]);
// Seed Etiquette Mutations // Seed Etiquette Mutations
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Interrupted Domina Alice during daily instructions', 'approved'); Mutation::create([
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 10, 'Forgot correct posture during morning roll call', 'approved'); 'ledger_id' => $etiquetteLedger->id,
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Spoke out of turn in general chat', 'approved'); 'user_id' => $bob->id,
$activityService->createMutation($etiquetteLedger, $bob, 'reward', -5, 'Excellent reciting of the house codes', 'approved'); 'type' => 'penalty',
'amount' => 5,
'description' => 'Interrupted Domina Alice during daily instructions',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'penalty',
'amount' => 10,
'description' => 'Forgot correct posture during morning roll call',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'penalty',
'amount' => 5,
'description' => 'Spoke out of turn in general chat',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => -5,
'description' => 'Excellent reciting of the house codes',
'status' => 'approved',
]);
// ---------------------------------------------------- // ----------------------------------------------------
// 3. Seed Dynamic 2: Obsidian Household Agreement // 3. Seed Dynamic 2: Obsidian Household Agreement
// ---------------------------------------------------- // ----------------------------------------------------
$obsidianHousehold = Dynamic::create([ $obsidianHousehold = Dynamic::create([
'name' => 'Obsidian Household Agreement', 'name' => 'Obsidian Household Agreement',
'rules' => "1. All residents must do their fair share of maintenance. 'rules' => "1. All residents must do their fair share of maintenance.\n2. Coffee machine must be refilled immediately when empty.",
2. Coffee machine must be refilled immediately when empty.",
]); ]);
$obsidianHousehold->participants()->attach($alice->id, ['role' => 'owner']); $obsidianHousehold->participants()->attach($alice->id, ['role' => 'owner']);
@@ -137,10 +261,29 @@ class DatabaseSeeder extends Seeder
$obsidianChat = $obsidianHousehold->chat; $obsidianChat = $obsidianHousehold->chat;
$activityService->createMessage($obsidianChat, $alice, "Who finished the coffee beans and didn't put them on the shopping list?"); Message::create([
$activityService->createMessage($obsidianChat, $charles, "Wasn't me, I only drink tea."); 'chat_id' => $obsidianChat->id,
$activityService->createMessage($obsidianChat, $testUser, 'My apologies! I did refill the hopper, but forgot to list the replacement bag. I will buy a new pack tonight.'); 'user_id' => $alice->id,
$activityService->createMessage($obsidianChat, $alice, 'Thank you, Test User. Appreciate the honesty.'); 'content' => "Who finished the coffee beans and didn't put them on the shopping list?",
]);
Message::create([
'chat_id' => $obsidianChat->id,
'user_id' => $charles->id,
'content' => "Wasn't me, I only drink tea.",
]);
Message::create([
'chat_id' => $obsidianChat->id,
'user_id' => $testUser->id,
'content' => 'My apologies! I did refill the hopper, but forgot to list the replacement bag. I will buy a new pack tonight.',
]);
Message::create([
'chat_id' => $obsidianChat->id,
'user_id' => $alice->id,
'content' => 'Thank you, Test User. Appreciate the honesty.',
]);
// Add Ledgers // Add Ledgers
$kitchenLedger = Ledger::create([ $kitchenLedger = Ledger::create([
@@ -160,11 +303,33 @@ class DatabaseSeeder extends Seeder
]); ]);
// Seed Chores Mutations // Seed Chores Mutations
$activityService->createMutation($kitchenLedger, $testUser, 'reward', 25, 'Emptied and loaded the dishwasher', 'approved'); Mutation::create([
$activityService->createMutation($kitchenLedger, $testUser, 'reward', 15, 'Took out recycling and trash bags', 'approved'); 'ledger_id' => $kitchenLedger->id,
'user_id' => $testUser->id,
'type' => 'reward',
'amount' => 25,
'description' => 'Emptied and loaded the dishwasher',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $kitchenLedger->id,
'user_id' => $testUser->id,
'type' => 'reward',
'amount' => 15,
'description' => 'Took out recycling and trash bags',
'status' => 'approved',
]);
// Seed Coffee Mutations // Seed Coffee Mutations
$activityService->createMutation($coffeeLedger, $testUser, 'reward', 10, 'Descaled and refilled coffee beans', 'approved'); Mutation::create([
'ledger_id' => $coffeeLedger->id,
'user_id' => $testUser->id,
'type' => 'reward',
'amount' => 10,
'description' => 'Descaled and refilled coffee beans',
'status' => 'approved',
]);
}); });
} }
} }
+3 -2
View File
@@ -13,5 +13,6 @@
@import './components/password-input.css'; @import './components/password-input.css';
@import './components/auth-layout.css'; @import './components/auth-layout.css';
@import './components/chat.css'; @import './components/chat.css';
@import './components/lightbox.css'; @import "./components/lightbox.css";
@import './components/invite-form.css'; @import "./components/invite-form.css";
/*@import "./components/display-name-form.css";*/
+14
View File
@@ -10,6 +10,19 @@
.c-chat__list { .c-chat__list {
@apply mt-4 flex flex-col gap-3; @apply mt-4 flex flex-col gap-3;
.c-chat__load-more {
@apply mb-4 text-center;
.c-chat__load-more-btn {
@apply text-sm font-semibold;
color: var(--primary);
&:hover {
text-decoration: underline;
}
}
}
.c-chat__message { .c-chat__message {
@apply overflow-hidden p-4 shadow-sm sm:rounded-lg; @apply overflow-hidden p-4 shadow-sm sm:rounded-lg;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -208,3 +221,4 @@
} }
} }
} }
.c-chat__user-link { @apply font-semibold text-blue-500 hover:underline; }
+178 -43
View File
@@ -1,27 +1,67 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { useForm, usePage, router } from '@inertiajs/vue3';
import { useForm, usePage } from '@inertiajs/vue3';
import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue'; import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue';
import { route } from 'ziggy-js';
import { Paperclip, Info } from '@lucide/vue'; import { Paperclip, Info } from '@lucide/vue';
import { ref, computed } from 'vue';
import { route } from 'ziggy-js';
const props = defineProps<{ const props = withDefaults(
chat: { defineProps<{
id: number; chat: {
messages: Array<{
id: number; id: number;
user: { id: number; name: string }; messages?: Array<{
content: string;
created_at: string;
media?: Array<{
id: number; id: number;
url: string; user: { id: number; name: string } | null;
file_name: string; content: string;
mime_type: string; created_at: string;
subject_id?: number | null;
subject_type?: string | null;
subject?: any;
media?: Array<{
id: number;
url: string;
file_name: string;
mime_type: string;
}>;
}>; }>;
};
participants?: Array<{
id: number;
name: string;
pivot?: {
display_name: string | null;
} | null;
}>; }>;
}; dynamicId: number;
}>(); initialMessages: {
data: Array<any>;
next_page_url: string | null;
};
}>(),
{
participants: () => [],
}
);
const messages = ref(props.initialMessages.data.reverse());
const nextPageUrl = ref(props.initialMessages.next_page_url);
function loadMoreMessages() {
if (!nextPageUrl.value) {
return;
}
router.get(nextPageUrl.value, {}, {
preserveState: true,
preserveScroll: true,
only: ['messages'],
onSuccess: (page) => {
const newMessages = page.props.messages as { data: Array<any>; next_page_url: string | null };
messages.value = [...newMessages.data.reverse(), ...messages.value];
nextPageUrl.value = newMessages.next_page_url;
},
});
}
if (!echoIsConfigured()) { if (!echoIsConfigured()) {
configureEcho({ configureEcho({
@@ -47,9 +87,99 @@ const form = useForm({
}); });
useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => { useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => {
props.chat.messages.push(e.message); messages.value.push(e.message);
}); });
function formatTimestamp(isoString: string): { full: string; time: string } {
const date = new Date(isoString);
return {
full: date.toLocaleString(),
time: date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
};
}
const participantsById = computed(() => {
const list = props.participants || [];
return list.reduce(
(acc, p) => {
acc[p.id] = p;
return acc;
},
{} as Record<
number,
{
id: number;
name: string;
pivot?: { display_name: string | null } | null;
}
>,
);
});
function parseMessageContent(message: {
content: string;
subject_id?: number | null;
subject_type?: string | null;
subject?: any;
}) {
let content = message.content;
// 1. Replace <user:id> placeholders with links to their dynamic profile
const userRegex = /<user:(\d+)>/g;
content = content.replace(userRegex, (match, userId) => {
const user = participantsById.value[Number(userId)];
if (user) {
const url = route('dynamics.users.show', [props.dynamicId, Number(userId)]);
return `<a href="${url}" class="c-chat__user-link font-semibold text-blue-500 hover:underline">${
user.pivot?.display_name ?? user.name
}</a>`;
}
return `User #${userId}`;
});
// 2. Link subjects if found in the text
if (message.subject_id && message.subject_type) {
if (
message.subject_type === 'App\\Models\\Mutation' ||
message.subject_type === 'App\\Models\\Ledger'
) {
const ledgerId =
message.subject_type === 'App\\Models\\Mutation'
? message.subject?.ledger_id
: message.subject?.id;
const ledgerName =
message.subject_type === 'App\\Models\\Mutation'
? message.subject?.ledger?.name
: message.subject?.name;
if (ledgerId && ledgerName) {
const ledgerUrl = route('dynamics.ledgers.show', [
props.dynamicId,
ledgerId,
]);
const escapedName = ledgerName.replace(
/[-\/\\^$*+?.()|[\]{}]/g,
'\\$&',
);
const nameRegex = new RegExp(`"${escapedName}"`, 'g');
content = content.replace(
nameRegex,
`"<a href="${ledgerUrl}" class="c-chat__subject-link font-semibold text-blue-500 hover:underline">${ledgerName}</a>"`,
);
}
}
}
return content;
}
function handleFileChange(event: Event) { function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files; const files = (event.target as HTMLInputElement).files;
if (files) { if (files) {
@@ -65,7 +195,10 @@ function removeFile(index: number) {
const currentUser = computed(() => usePage().props.auth?.user); const currentUser = computed(() => usePage().props.auth?.user);
function isOwnMessage(messageUserId: number): boolean { function isOwnMessage(messageUserId: number | null): boolean {
if (messageUserId === null) {
return false;
}
return currentUser.value && currentUser.value.id === messageUserId; return currentUser.value && currentUser.value.id === messageUserId;
} }
@@ -101,34 +234,37 @@ function closeLightbox() {
<div class="c-chat"> <div class="c-chat">
<h4 class="c-chat__title">Chat</h4> <h4 class="c-chat__title">Chat</h4>
<div class="c-chat__list"> <div class="c-chat__list">
<div v-if="nextPageUrl" class="c-chat__load-more">
<button @click="loadMoreMessages" class="c-chat__load-more-btn">
Load More
</button>
</div>
<div <div
v-for="message in chat.messages" v-for="message in messages"
:key="message.id" :key="message.id"
:class="[ :class="[
'c-chat__message', 'c-chat__message',
{ {
'c-chat__message--system': 'c-chat__message--system': message.user === null,
message.user.id === 0, 'c-chat__message--own': isOwnMessage(message.user?.id),
'c-chat__message--own': isOwnMessage(message.user.id), 'c-chat__message--other': message.user !== null && !isOwnMessage(message.user?.id)
'c-chat__message--other': !isOwnMessage(
message.user.id,
),
}, },
]" ]"
> >
<!-- Standard User Chat Message --> <!-- Standard User Chat Message -->
<template v-if="!message.content.startsWith('System:')"> <template v-if="message.user">
<div class="c-chat__message-header"> <div class="c-chat__message-header">
<span class="c-chat__message-author">{{ <span class="c-chat__message-author">{{
message.user.name message.user.name
}}</span> }}</span>
<span class="c-chat__message-time">{{ <span
new Date(message.created_at).toLocaleString() class="c-chat__message-time"
}}</span> :title="formatTimestamp(message.created_at).full"
>
{{ formatTimestamp(message.created_at).time }}
</span>
</div> </div>
<p class="c-chat__message-text"> <p class="c-chat__message-text" v-html="parseMessageContent(message)"></p>
{{ message.content }}
</p>
<!-- Attached Media Display --> <!-- Attached Media Display -->
<div <div
@@ -166,21 +302,20 @@ function closeLightbox() {
<template v-else> <template v-else>
<div class="c-chat__system-inner"> <div class="c-chat__system-inner">
<Info class="c-chat__system-icon" /> <Info class="c-chat__system-icon" />
<span class="c-chat__system-text"> <span
{{ message.content.replace(/^System:\s*/, '') }} class="c-chat__system-text"
</span> v-html="parseMessageContent(message)"
<span class="c-chat__system-time"> ></span>
{{ <span
new Date(message.created_at).toLocaleTimeString( class="c-chat__system-time"
[], :title="formatTimestamp(message.created_at).full"
{ hour: '2-digit', minute: '2-digit' }, >
) {{ formatTimestamp(message.created_at).time }}
}}
</span> </span>
</div> </div>
</template> </template>
</div> </div>
<div v-if="chat.messages.length === 0" class="c-chat__empty"> <div v-if="messages.length === 0" class="c-chat__empty">
No messages yet. No messages yet.
</div> </div>
</div> </div>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
const props = defineProps<{
message: {
id: number;
user: { id: number; name: string } | null;
content: string;
created_at: string;
media?: Array<{
id: number;
url: string;
file_name: string;
mime_type: string;
}>;
};
}>();
const processedContent = computed(() => {
return props.message.content.replace(/<user:(\d+)>/g, (match, userId) => {
// This is a placeholder for a more robust user lookup
return `<a href="${route('users.show', userId)}" class="text-blue-500 hover:underline">@user${userId}</a>`;
});
});
</script>
<template>
<div v-html="processedContent"></div>
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import InputError from '@/components/InputError.vue';
const props = defineProps<{
dynamic: {
id: number;
name: string;
pivot: {
display_name: string | null;
};
};
}>();
const form = useForm({
display_name: props.dynamic.pivot?.display_name ?? '',
});
function submit() {
form.put(route('dynamics.participant.update', props.dynamic.id), {
preserveScroll: true,
});
}
</script>
<template>
<form @submit.prevent="submit" class="flex flex-col sm:flex-row items-start sm:items-center gap-4 p-4 border rounded-lg bg-card text-card-foreground">
<div class="flex-1 w-full">
<div class="font-medium text-sm mb-1">{{ dynamic.name }}</div>
<Input
v-model="form.display_name"
class="w-full"
placeholder="Enter custom display name"
required
/>
<InputError class="mt-1" :message="form.errors.display_name" />
</div>
<div class="sm:self-end">
<Button type="submit" size="sm" :disabled="form.processing">
Save
</Button>
</div>
</form>
</template>
+1 -1
View File
@@ -174,7 +174,7 @@ function getAmountClass(amount: number): string {
</button> </button>
</div> </div>
<Chat :chat="mutation.chat" /> <Chat :chat="mutation.chat" :dynamic-id="dynamicId" :participants="participants" />
</li> </li>
</ul> </ul>
<div v-if="mutations.length === 0" class="c-mutation-list__empty"> <div v-if="mutations.length === 0" class="c-mutation-list__empty">
+13 -1
View File
@@ -1,8 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
defineProps<{ defineProps<{
dynamicId: number;
participants: Array<{ participants: Array<{
id: number; id: number;
name: string; name: string;
pivot: {
display_name: string | null;
};
}>; }>;
}>(); }>();
</script> </script>
@@ -16,7 +23,12 @@ defineProps<{
:key="participant.id" :key="participant.id"
class="c-participants-list__item" class="c-participants-list__item"
> >
{{ participant.name }} <Link
:href="route('dynamics.users.show', [dynamicId, participant.id])"
class="block"
>
{{ participant.pivot.display_name ?? participant.name }}
</Link>
</li> </li>
</ul> </ul>
</div> </div>
+2 -3
View File
@@ -107,11 +107,11 @@ function formatTime(isoString: string): string {
<!-- Unread Separator Line --> <!-- Unread Separator Line -->
<div <div
v-if="entity.context_activities.length > 0" v-if="entity.new_activities.length > 0"
class="c-dashboard__divider" class="c-dashboard__divider"
> >
<span class="c-dashboard__divider-text" <span class="c-dashboard__divider-text"
>New Activity Below</span >NEW</span
> >
</div> </div>
@@ -129,7 +129,6 @@ function formatTime(isoString: string): string {
<span class="c-dashboard__activity-time"> <span class="c-dashboard__activity-time">
{{ formatTime(activity.created_at) }} {{ formatTime(activity.created_at) }}
</span> </span>
<span class="c-dashboard__new-badge">NEW</span>
</div> </div>
<p class="c-dashboard__activity-desc"> <p class="c-dashboard__activity-desc">
{{ activity.content }} {{ activity.content }}
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
defineOptions({
layout: {
breadcrumbs: [
{
name: 'Dynamics',
href: route('dynamics.index'),
isCurrent: false,
},
{
name: 'dynamic.name',
href: 'route(\'dynamics.show\', dynamic.id)',
isCurrent: false,
},
{
name: 'participant.display_name',
href: 'route(\'dynamics.users.show\', { dynamic: dynamic.id, user: participant.id })',
isCurrent: true,
},
],
},
});
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
participant: {
id: number;
name: string;
display_name: string | null;
role: string;
};
mutations: Array<{
id: number;
amount: number;
description: string;
status: string;
created_at: string;
ledger: { id: number; name: string };
}>;
}>();
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
name: props.participant.display_name ?? props.participant.name,
href: route('dynamics.users.show', [props.dynamic.id, props.participant.id]),
},
];
</script>
<template>
<Head :title="participant.display_name ?? participant.name" />
<div class="c-participant-show">
<div class="c-participant-show__container">
<h2 class="c-participant-show__title">
Activity for {{ participant.display_name ?? participant.name }} ({{ participant.role.toUpperCase() }}) in {{ dynamic.name }}
</h2>
<div class="c-participant-show__activity-list">
<div
v-for="mutation in mutations"
:key="mutation.id"
class="c-participant-show__activity-item"
>
<Link :href="route('dynamics.ledgers.show', [dynamic.id, mutation.ledger.id])" class="block">
<div class="c-participant-show__activity-meta">
<span class="c-participant-show__activity-time">
{{ new Date(mutation.created_at).toLocaleString() }}
</span>
<span class="font-semibold ml-2" :class="mutation.amount > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
{{ mutation.amount > 0 ? '+' : '' }}{{ mutation.amount }}
</span>
<span class="text-neutral-400 ml-2">on {{ mutation.ledger.name }}</span>
<span class="uppercase text-xs px-1.5 py-0.5 rounded ml-auto" :class="mutation.status === 'approved' ? 'bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400'">
{{ mutation.status }}
</span>
</div>
<p class="c-participant-show__activity-desc">
{{ mutation.description }}
</p>
</Link>
</div>
<div v-if="mutations.length === 0" class="text-neutral-500 text-sm">
No mutations recorded for this participant in this Dynamic yet.
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "../../../../css/app.css";
.c-participant-show {
@apply py-12;
}
.c-participant-show__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-participant-show__title {
@apply mb-6 text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100;
}
.c-participant-show__activity-list {
@apply space-y-4;
}
.c-participant-show__activity-item {
@apply rounded-lg border p-4 transition-colors hover:bg-neutral-50 dark:border-neutral-800 dark:hover:bg-neutral-900;
background-color: var(--card);
}
.c-participant-show__activity-meta {
@apply mb-1.5 flex items-center text-xs;
}
.c-participant-show__activity-time {
@apply text-neutral-400 dark:text-neutral-500;
}
.c-participant-show__activity-desc {
@apply text-sm text-neutral-600 dark:text-neutral-400 mt-1;
}
</style>
-6
View File
@@ -70,12 +70,6 @@ function submit() {
</form> </form>
</div> </div>
</div> </div>
<div class="mt-8">
<InertiaLink :href="route('dynamics.predefined-mutations.index', dynamic.id)" class="c-dynamic-settings__submit-btn">
Manage Predefined Mutations
</InertiaLink>
</div>
</div> </div>
</div> </div>
</template> </template>
+25 -3
View File
@@ -5,13 +5,30 @@ import LedgerList from '@/components/LedgerList.vue';
import { Head, Link as InertiaLink } from '@inertiajs/vue3'; import { Head, Link as InertiaLink } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
defineOptions({
layout: {
breadcrumbs: [
{
name: 'Dynamics',
href: route('dynamics.index'),
isCurrent: false,
},
{
name: 'dynamic.name',
href: 'route(\'dynamics.show\', dynamic.id)',
isCurrent: true,
},
],
},
});
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
id: number; id: number;
name: string; name: string;
rules: string; rules: string;
chat: any; chat: any;
participants: Array<{ id: number; name: string }>; participants: Array<{ id: number; name: string, pivot: { display_name: string | null } }>;
ledgers: Array<{ ledgers: Array<{
id: number; id: number;
name: string; name: string;
@@ -21,8 +38,13 @@ const props = defineProps<{
}>; }>;
}; };
isOwner: boolean; isOwner: boolean;
messages: {
data: Array<any>;
next_page_url: string | null;
};
}>(); }>();
const breadcrumbs = [ const breadcrumbs = [
{ {
name: 'Dynamics', name: 'Dynamics',
@@ -57,10 +79,10 @@ const breadcrumbs = [
</div> </div>
<!-- Dynamic Chat --> <!-- Dynamic Chat -->
<Chat :chat="dynamic.chat" /> <Chat :chat="dynamic.chat" :initial-messages="messages" :participants="dynamic.participants" :dynamic-id="dynamic.id" />
<!-- Participants Component --> <!-- Participants Component -->
<ParticipantsList :participants="dynamic.participants" /> <ParticipantsList :dynamic-id="dynamic.id" :participants="dynamic.participants" />
<!-- Ledgers List Component --> <!-- Ledgers List Component -->
<LedgerList :dynamic-id="dynamic.id" :ledgers="dynamic.ledgers" /> <LedgerList :dynamic-id="dynamic.id" :ledgers="dynamic.ledgers" />
@@ -9,12 +9,15 @@ const props = defineProps<{
id: number; id: number;
name: string; name: string;
}; };
ledger: {
id: number;
name: string;
};
predefined_mutations: Array<{ predefined_mutations: Array<{
id: number; id: number;
name: string; name: string;
description: string; description: string;
amount: number; amount: number;
type: string;
}>; }>;
}>(); }>();
@@ -22,7 +25,6 @@ const form = useForm({
name: '', name: '',
description: '', description: '',
amount: 0, amount: 0,
type: 'reward',
}); });
const breadcrumbs = [ const breadcrumbs = [
@@ -34,14 +36,18 @@ const breadcrumbs = [
name: props.dynamic.name, name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id), href: route('dynamics.show', props.dynamic.id),
}, },
{
name: props.ledger.name,
href: route('dynamics.ledgers.show', [props.dynamic.id, props.ledger.id]),
},
{ {
name: 'Predefined Mutations', name: 'Predefined Mutations',
href: route('dynamics.predefined-mutations.index', props.dynamic.id), href: route('dynamics.ledgers.predefined-mutations.index', [props.dynamic.id, props.ledger.id]),
}, },
]; ];
function submit() { function submit() {
form.post(route('dynamics.predefined-mutations.store', props.dynamic.id), { form.post(route('dynamics.ledgers.predefined-mutations.store', [props.dynamic.id, props.ledger.id]), {
onSuccess: () => form.reset(), onSuccess: () => form.reset(),
}); });
} }
@@ -56,7 +62,7 @@ function submit() {
<div class="c-predefined-mutations__card"> <div class="c-predefined-mutations__card">
<div class="c-predefined-mutations__body"> <div class="c-predefined-mutations__body">
<h3 class="c-predefined-mutations__title"> <h3 class="c-predefined-mutations__title">
Predefined Mutations for {{ dynamic.name }} Predefined Mutations for {{ ledger.name }}
</h3> </h3>
<div class="c-predefined-mutations__list"> <div class="c-predefined-mutations__list">
@@ -133,22 +139,6 @@ function submit() {
/> />
</div> </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"> <div class="c-predefined-mutations__actions">
<button <button
type="submit" type="submit"
+53 -8
View File
@@ -1,11 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head, Link as InertiaLink } from '@inertiajs/vue3';
import { useEcho } from '@laravel/echo-vue'; import { useEcho } from '@laravel/echo-vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import AddMutationForm from '@/components/AddMutationForm.vue'; import AddMutationForm from '@/components/AddMutationForm.vue';
import Chat from '@/components/Chat.vue';
import MutationList from '@/components/MutationList.vue'; import MutationList from '@/components/MutationList.vue';
defineOptions({
layout: {
breadcrumbs: [
{
name: 'Dynamics',
href: route('dynamics.index'),
isCurrent: false,
},
{
name: 'dynamic.name',
href: 'route(\'dynamics.show\', dynamic.id)',
isCurrent: false,
},
{
name: 'ledger.name',
href: 'route(\'dynamics.ledgers.show\', { dynamic: dynamic.id, ledger: ledger.id })',
isCurrent: true,
},
],
},
});
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
id: number; id: number;
@@ -37,6 +60,10 @@ const props = defineProps<{
}>; }>;
}; };
isOwner: boolean; isOwner: boolean;
messages: {
data: Array<any>;
next_page_url: string | null;
};
}>(); }>();
const breadcrumbs = [ const breadcrumbs = [
@@ -168,13 +195,25 @@ function isOwnerUser(userId: number): boolean {
<div class="c-ledger-show__container"> <div class="c-ledger-show__container">
<div class="c-ledger-show__card"> <div class="c-ledger-show__card">
<div class="c-ledger-show__body"> <div class="c-ledger-show__body">
<h3 class="c-ledger-show__title">{{ ledger.name }}</h3> <div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4">
<p class="c-ledger-show__score"> <div>
Score: {{ ledger.score }} <h3 class="c-ledger-show__title">{{ ledger.name }}</h3>
</p> <p class="c-ledger-show__score">
<p class="c-ledger-show__rules"> Score: {{ ledger.score }}
{{ ledger.rules }} </p>
</p> <p class="c-ledger-show__rules">
{{ ledger.rules }}
</p>
</div>
<div v-if="isOwner" class="flex flex-col gap-2">
<InertiaLink
:href="route('dynamics.ledgers.predefined-mutations.index', [dynamic.id, ledger.id])"
class="c-ledger-show__manage-btn"
>
Predefined Mutations
</InertiaLink>
</div>
</div>
<!-- Ledger Alignment Badge / Subtitle --> <!-- Ledger Alignment Badge / Subtitle -->
<div class="c-ledger-show__alignment-wrapper"> <div class="c-ledger-show__alignment-wrapper">
@@ -246,6 +285,8 @@ function isOwnerUser(userId: number): boolean {
:ledger-alignment="ledger.alignment" :ledger-alignment="ledger.alignment"
@open-lightbox="openLightbox" @open-lightbox="openLightbox"
/> />
<Chat :chat="dynamic.chat" :initial-messages="messages" :participants="dynamic.participants" :dynamic-id="dynamic.id" />
</div> </div>
</div> </div>
@@ -288,6 +329,10 @@ function isOwnerUser(userId: number): boolean {
@apply py-12; @apply py-12;
} }
.c-ledger-show__manage-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-show__container { .c-ledger-show__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8; @apply mx-auto max-w-7xl sm:px-6 lg:px-8;
} }
+260
View File
@@ -0,0 +1,260 @@
<script setup lang="ts">
import { Head, Link as InertiaLink } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
participant: {
id: number;
name: string;
display_name: string | null;
role: string;
};
mutations: Array<{
id: number;
ledger_id: number;
ledger: {
id: number;
name: string;
alignment: string;
};
amount: number;
description: string;
status: string;
created_at: string;
}>;
}>();
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
name: props.participant.display_name ?? props.participant.name,
href: route('dynamics.users.show', [props.dynamic.id, props.participant.id]),
},
];
function getAmountClass(amount: number, alignment: string): string {
if (alignment === 'positive') {
return amount > 0
? 'c-participant-show__activity-amount--positive'
: 'c-participant-show__activity-amount--negative';
}
if (alignment === 'negative') {
return amount < 0
? 'c-participant-show__activity-amount--positive'
: 'c-participant-show__activity-amount--negative';
}
return 'c-participant-show__activity-amount--neutral';
}
</script>
<template>
<Head :title="participant.display_name ?? participant.name" />
<div class="c-participant-show">
<div class="c-participant-show__container">
<!-- Header Card -->
<div class="c-participant-show__card">
<div class="c-participant-show__body">
<div class="flex justify-between items-center">
<div>
<span class="c-participant-show__role-badge">
{{ participant.role }}
</span>
<h3 class="c-participant-show__title mt-1">
{{ participant.display_name ?? participant.name }}
</h3>
<p v-if="participant.display_name" class="c-participant-show__subtitle">
Real Name: {{ participant.name }}
</p>
</div>
<InertiaLink :href="route('dynamics.show', dynamic.id)" class="c-participant-show__back-btn">
Back to Dynamic
</InertiaLink>
</div>
</div>
</div>
<!-- Recent Activity Block -->
<div class="c-participant-show__activity mt-8">
<h4 class="c-participant-show__activity-title">Recent Activity</h4>
<div v-if="mutations.length > 0" class="c-participant-show__activity-list mt-4">
<div
v-for="mutation in mutations"
:key="mutation.id"
class="c-participant-show__activity-item"
>
<div class="flex justify-between items-start">
<div>
<InertiaLink
:href="route('dynamics.ledgers.show', [dynamic.id, mutation.ledger_id])"
class="c-participant-show__activity-ledger"
>
{{ mutation.ledger.name }}
</InertiaLink>
<p class="c-participant-show__activity-desc mt-1">
{{ mutation.description }}
</p>
</div>
<div class="text-right">
<span
:class="getAmountClass(mutation.amount, mutation.ledger.alignment)"
class="c-participant-show__activity-amount"
>
{{ mutation.amount > 0 ? '+' : '' }}{{ mutation.amount }}
</span>
<div class="c-participant-show__activity-meta mt-1">
<span
:class="{
'c-participant-show__activity-status--pending': mutation.status === 'pending',
'c-participant-show__activity-status--approved': mutation.status === 'approved',
'c-participant-show__activity-status--rejected': mutation.status === 'rejected',
}"
class="c-participant-show__activity-status"
>
{{ mutation.status }}
</span>
<span class="c-participant-show__activity-time ml-2">
{{ new Date(mutation.created_at).toLocaleDateString() }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="c-participant-show__empty mt-4">
No recent activity found for this participant.
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "../../../css/app.css";
.c-participant-show {
@apply py-12;
}
.c-participant-show__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-participant-show__card {
@apply overflow-hidden;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.c-participant-show__body {
@apply p-6;
color: var(--foreground);
}
.c-participant-show__role-badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold uppercase tracking-wider;
background-color: var(--primary);
color: var(--primary-foreground);
opacity: 0.8;
}
.c-participant-show__title {
@apply text-2xl font-bold;
}
.c-participant-show__subtitle {
@apply mt-1 text-sm;
color: var(--muted-foreground);
}
.c-participant-show__back-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-participant-show__activity-title {
@apply text-lg font-medium;
color: var(--foreground);
}
.c-participant-show__activity-list {
@apply space-y-4;
}
.c-participant-show__activity-item {
@apply p-4;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.c-participant-show__activity-ledger {
@apply font-semibold hover:underline text-sm;
color: var(--primary);
}
.c-participant-show__activity-desc {
@apply text-sm;
color: var(--foreground);
}
.c-participant-show__activity-amount {
@apply font-bold text-sm;
}
.c-participant-show__activity-amount--positive {
@apply text-green-500;
}
.c-participant-show__activity-amount--negative {
@apply text-red-500;
}
.c-participant-show__activity-amount--neutral {
@apply text-neutral-500;
}
.c-participant-show__activity-meta {
@apply flex items-center justify-end text-xs;
}
.c-participant-show__activity-status {
@apply rounded px-1.5 py-0.5 text-[9px] font-medium tracking-wider uppercase;
}
.c-participant-show__activity-status--pending {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400;
}
.c-participant-show__activity-status--approved {
@apply bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400;
}
.c-participant-show__activity-status--rejected {
@apply bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400;
}
.c-participant-show__activity-time {
color: var(--muted-foreground);
}
.c-participant-show__empty {
@apply text-sm text-center py-8;
color: var(--muted-foreground);
}
</style>
+34
View File
@@ -6,6 +6,7 @@ import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileCo
import DeleteUser from '@/components/DeleteUser.vue'; import DeleteUser from '@/components/DeleteUser.vue';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import DynamicDisplayNameItem from '@/components/DynamicDisplayNameItem.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -23,6 +24,18 @@ defineOptions({
}, },
}); });
defineProps<{
mustVerifyEmail: boolean;
status: string | null;
dynamics: Array<{
id: number;
name: string;
pivot: {
display_name: string | null;
};
}>;
}>();
const page = usePage(); const page = usePage();
const user = computed(() => page.props.auth.user); const user = computed(() => page.props.auth.user);
</script> </script>
@@ -99,6 +112,27 @@ const user = computed(() => page.props.auth.user);
> >
</div> </div>
</Form> </Form>
<!-- Dynamic-Specific Display Names Section -->
<div class="pt-8 border-t">
<Heading
variant="small"
title="Dynamic Display Names"
description="Customize your display name for each of your dynamics"
/>
<div class="mt-6 space-y-4">
<div v-if="dynamics && dynamics.length > 0" class="space-y-4">
<DynamicDisplayNameItem
v-for="dyn in dynamics"
:key="dyn.id"
:dynamic="dyn"
/>
</div>
<p v-else class="text-sm text-muted-foreground">
You are not a participant in any dynamics yet.
</p>
</div>
</div>
</div> </div>
<DeleteUser /> <DeleteUser />
+6 -1
View File
@@ -15,11 +15,13 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('dynamics', DynamicController::class)->except(['edit', 'update']); Route::resource('dynamics', DynamicController::class)->except(['edit', 'update']);
Route::get('/dynamics/{dynamic}/settings', [DynamicController::class, 'edit'])->name('dynamics.edit'); Route::get('/dynamics/{dynamic}/settings', [DynamicController::class, 'edit'])->name('dynamics.edit');
Route::patch('/dynamics/{dynamic}/settings', [DynamicController::class, 'update'])->name('dynamics.update'); Route::patch('/dynamics/{dynamic}/settings', [DynamicController::class, 'update'])->name('dynamics.update');
Route::get('/dynamics/{dynamic}/messages', [DynamicController::class, 'messages'])->name('dynamics.messages');
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::resource('dynamics.ledgers', LedgerController::class)->scoped()->except(['create']); Route::resource('dynamics.ledgers', LedgerController::class)->scoped()->except(['create']);
Route::resource('dynamics.predefined-mutations', \App\Http\Controllers\PredefinedMutationController::class)->scoped(); Route::resource('dynamics.ledgers.predefined-mutations', \App\Http\Controllers\PredefinedMutationController::class)->scoped();
Route::resource('dynamics.ledgers.mutations', MutationController::class)->scoped(); Route::resource('dynamics.ledgers.mutations', MutationController::class)->scoped();
@@ -27,6 +29,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/dynamics/{dynamic}/invitations', [\App\Http\Controllers\DynamicInvitationController::class, 'store'])->name('dynamics.invitations.store'); Route::post('/dynamics/{dynamic}/invitations', [\App\Http\Controllers\DynamicInvitationController::class, 'store'])->name('dynamics.invitations.store');
Route::post('/chats/{chat}/messages', [MessageController::class, 'store'])->name('chats.messages.store'); Route::post('/chats/{chat}/messages', [MessageController::class, 'store'])->name('chats.messages.store');
Route::put('/dynamics/{dynamic}/participant', [\App\Http\Controllers\ParticipantController::class, 'update'])->name('dynamics.participant.update');
Route::get('/dynamics/{dynamic}/users/{user}', [\App\Http\Controllers\ParticipantController::class, 'show'])->name('dynamics.users.show');
}); });
Route::get('/invitations/accept/{token}', [\App\Http\Controllers\DynamicInvitationController::class, 'accept']) Route::get('/invitations/accept/{token}', [\App\Http\Controllers\DynamicInvitationController::class, 'accept'])
+1 -1
View File
@@ -99,5 +99,5 @@ test('only the user with the specified email address can accept the link', funct
// Verify system notification is added to Dynamic activity chat // Verify system notification is added to Dynamic activity chat
$chatMessages = $dynamic->chat->messages; $chatMessages = $dynamic->chat->messages;
expect($chatMessages)->not->toBeEmpty(); expect($chatMessages)->not->toBeEmpty();
expect($chatMessages->last()->content)->toBe("{$invitee->name} joined the Dynamic as a EDITOR"); expect($chatMessages->last()->content)->toBe("<user:{$invitee->id}> joined the Dynamic as a EDITOR");
}); });
+12 -6
View File
@@ -32,11 +32,13 @@ test('owner can create a mutation which is automatically approved and does not s
// Verify chat messages (should NOT say "approved" or "Approved") // Verify chat messages (should NOT say "approved" or "Approved")
$mutationChatMessages = $mutation->chat->messages; $mutationChatMessages = $mutation->chat->messages;
expect($mutationChatMessages)->toHaveCount(1); expect($mutationChatMessages)->toHaveCount(1);
expect($mutationChatMessages->first()->content)->toBe("System: Entry was created by {$owner->name}."); expect($mutationChatMessages->first()->user_id)->toBeNull();
expect($mutationChatMessages->first()->content)->toBe("Entry was created by <user:{$owner->id}>.");
$dynamicChatMessages = $dynamic->chat->messages; $dynamicChatMessages = $dynamic->chat->messages;
expect($dynamicChatMessages)->toHaveCount(1); expect($dynamicChatMessages)->toHaveCount(1);
expect($dynamicChatMessages->first()->content)->toBe("System: {$owner->name} added entry \"Direct point reward\" for +15 points on \"{$ledger->name}\" ledger."); expect($dynamicChatMessages->first()->user_id)->toBeNull();
expect($dynamicChatMessages->first()->content)->toBe("<user:{$owner->id}> added entry \"Direct point reward\" for +15 points on \"{$ledger->name}\" ledger.");
}); });
test('non-owner participant creates a suggestion which defaults to pending and says suggested', function () { test('non-owner participant creates a suggestion which defaults to pending and says suggested', function () {
@@ -68,11 +70,13 @@ test('non-owner participant creates a suggestion which defaults to pending and s
// Verify chat messages // Verify chat messages
$mutationChatMessages = $mutation->chat->messages; $mutationChatMessages = $mutation->chat->messages;
expect($mutationChatMessages)->toHaveCount(1); expect($mutationChatMessages)->toHaveCount(1);
expect($mutationChatMessages->first()->content)->toBe("System: Suggestion was created by {$participant->name}."); expect($mutationChatMessages->first()->user_id)->toBeNull();
expect($mutationChatMessages->first()->content)->toBe("Suggestion was created by <user:{$participant->id}>.");
$dynamicChatMessages = $dynamic->chat->messages; $dynamicChatMessages = $dynamic->chat->messages;
expect($dynamicChatMessages)->toHaveCount(1); expect($dynamicChatMessages)->toHaveCount(1);
expect($dynamicChatMessages->first()->content)->toBe("System: {$participant->name} suggested \"Suggested point reward\" for +10 points on \"{$ledger->name}\" ledger."); expect($dynamicChatMessages->first()->user_id)->toBeNull();
expect($dynamicChatMessages->first()->content)->toBe("<user:{$participant->id}> suggested \"Suggested point reward\" for +10 points on \"{$ledger->name}\" ledger.");
}); });
test('owner can approve a pending suggestion and it is updated and logged', function () { test('owner can approve a pending suggestion and it is updated and logged', function () {
@@ -110,8 +114,10 @@ test('owner can approve a pending suggestion and it is updated and logged', func
$mutationChatMessages = $mutation->chat->messages; $mutationChatMessages = $mutation->chat->messages;
// Note: one from boot created (empty or via seeder, but in our factory it starts with 0 messages if not manually logged, // Note: one from boot created (empty or via seeder, but in our factory it starts with 0 messages if not manually logged,
// actually our model booted hook creates the chat but doesn't log on boot, the update method creates 1 message) // actually our model booted hook creates the chat but doesn't log on boot, the update method creates 1 message)
expect($mutationChatMessages->last()->content)->toBe("System: Suggestion was APPROVED by {$owner->name}."); expect($mutationChatMessages->last()->user_id)->toBeNull();
expect($mutationChatMessages->last()->content)->toBe("Suggestion was APPROVED by <user:{$owner->id}>.");
$dynamicChatMessages = $dynamic->chat->messages; $dynamicChatMessages = $dynamic->chat->messages;
expect($dynamicChatMessages->last()->content)->toBe("System: {$owner->name} APPROVED the suggestion \"Polished dungeon floors\" for +20 points on \"{$ledger->name}\" ledger."); expect($dynamicChatMessages->last()->user_id)->toBeNull();
expect($dynamicChatMessages->last()->content)->toBe("<user:{$owner->id}> APPROVED the suggestion \"Polished dungeon floors\" for +20 points on \"{$ledger->name}\" ledger.");
}); });
+95
View File
@@ -0,0 +1,95 @@
<?php
use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\Mutation;
test('authenticated participant can view another participant detail page in dynamic', function () {
$owner = User::factory()->create();
$participant = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner', 'display_name' => 'The Boss']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
$this->actingAs($owner);
$response = $this->get(route('dynamics.users.show', [$dynamic->id, $participant->id]));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Dynamics/Participants/Show')
->has('dynamic')
->where('participant.id', $participant->id)
->where('participant.name', $participant->name)
->where('participant.display_name', null)
->where('participant.role', 'participant')
->has('mutations')
);
});
test('non-participant cannot view participant detail page in dynamic', function () {
$owner = User::factory()->create();
$participant = User::factory()->create();
$outsider = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
$this->actingAs($outsider);
$response = $this->get(route('dynamics.users.show', [$dynamic->id, $participant->id]));
$response->assertStatus(403);
});
test('participant detail page displays their recent mutations in dynamic', function () {
$owner = User::factory()->create();
$participant = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant', 'display_name' => 'Bitch']);
// Create mutations in this dynamic
$mutation1 = Mutation::factory()->create([
'ledger_id' => $ledger->id,
'user_id' => $participant->id,
'amount' => 10,
'description' => 'Chore 1',
]);
$mutation2 = Mutation::factory()->create([
'ledger_id' => $ledger->id,
'user_id' => $participant->id,
'amount' => -5,
'description' => 'Infraction 1',
]);
// Create a mutation in another dynamic (should NOT be displayed)
$otherDynamic = Dynamic::factory()->create();
$otherLedger = Ledger::factory()->create(['dynamic_id' => $otherDynamic->id]);
$otherDynamic->participants()->attach($participant->id, ['role' => 'participant']);
$mutation3 = Mutation::factory()->create([
'ledger_id' => $otherLedger->id,
'user_id' => $participant->id,
'amount' => 100,
'description' => 'Other dynamic chore',
]);
$this->actingAs($owner);
$response = $this->get(route('dynamics.users.show', [$dynamic->id, $participant->id]));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Dynamics/Participants/Show')
->where('participant.display_name', 'Bitch')
->has('mutations', 2)
->where('mutations.0.description', 'Infraction 1') // Ordered latest first
->where('mutations.1.description', 'Chore 1')
);
});
+76
View File
@@ -0,0 +1,76 @@
<?php
use App\Models\User;
use App\Models\Dynamic;
test('user displayNameFor returns user name by default', function () {
$user = User::factory()->create(['name' => 'Alice']);
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($user->id, ['role' => 'participant']);
expect($user->displayNameFor($dynamic))->toBe('Alice');
});
test('user displayNameFor returns custom display name when set', function () {
$user = User::factory()->create(['name' => 'Alice']);
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($user->id, [
'role' => 'participant',
'display_name' => 'Ally',
]);
expect($user->displayNameFor($dynamic))->toBe('Ally');
});
test('participant can update their display name', function () {
$user = User::factory()->create(['name' => 'Alice']);
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($user->id, ['role' => 'participant']);
$this->actingAs($user);
$response = $this->put(route('dynamics.participant.update', $dynamic->id), [
'display_name' => 'Ally',
]);
$response->assertRedirect();
// Check database
$this->assertDatabaseHas('participants', [
'user_id' => $user->id,
'dynamic_id' => $dynamic->id,
'display_name' => 'Ally',
]);
// Check display name method
expect($user->displayNameFor($dynamic))->toBe('Ally');
});
test('display name update requires display_name parameter', function () {
$user = User::factory()->create(['name' => 'Alice']);
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($user->id, ['role' => 'participant']);
$this->actingAs($user);
$response = $this->put(route('dynamics.participant.update', $dynamic->id), [
'display_name' => '',
]);
$response->assertSessionHasErrors(['display_name']);
});
test('non participant cannot update display name', function () {
$user = User::factory()->create(['name' => 'Alice']);
$nonParticipant = User::factory()->create(['name' => 'Bob']);
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($user->id, ['role' => 'participant']);
$this->actingAs($nonParticipant);
$response = $this->put(route('dynamics.participant.update', $dynamic->id), [
'display_name' => 'Bobby',
]);
$response->assertStatus(404);
});
+92
View File
@@ -0,0 +1,92 @@
<?php
use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\PredefinedMutation;
test('owner can view predefined mutations for ledger', function () {
$owner = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$predefined = PredefinedMutation::create([
'ledger_id' => $ledger->id,
'name' => 'Weekly Room Cleaning',
'description' => 'Cleaned up the master bedroom',
'amount' => 20,
]);
$this->actingAs($owner);
$response = $this->get(route('dynamics.ledgers.predefined-mutations.index', [$dynamic->id, $ledger->id]));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Ledgers/PredefinedMutations/Index')
->where('ledger.id', $ledger->id)
->has('predefined_mutations', 1)
->where('predefined_mutations.0.name', 'Weekly Room Cleaning')
);
});
test('non-owner cannot view predefined mutations for ledger', function () {
$owner = User::factory()->create();
$participant = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($participant);
$response = $this->get(route('dynamics.ledgers.predefined-mutations.index', [$dynamic->id, $ledger->id]));
$response->assertStatus(403);
});
test('owner can create predefined mutations for ledger', function () {
$owner = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($owner);
$response = $this->post(route('dynamics.ledgers.predefined-mutations.store', [$dynamic->id, $ledger->id]), [
'name' => 'Polished mirrors',
'description' => 'Mirror polishing in dungeon',
'amount' => 15,
]);
$response->assertRedirect();
$this->assertDatabaseHas('predefined_mutations', [
'ledger_id' => $ledger->id,
'name' => 'Polished mirrors',
'amount' => 15,
]);
});
test('non-owner cannot create predefined mutations for ledger', function () {
$owner = User::factory()->create();
$participant = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($participant);
$response = $this->post(route('dynamics.ledgers.predefined-mutations.store', [$dynamic->id, $ledger->id]), [
'name' => 'Polished mirrors',
'description' => 'Mirror polishing in dungeon',
'amount' => 15,
]);
$response->assertStatus(403);
$this->assertDatabaseMissing('predefined_mutations', [
'ledger_id' => $ledger->id,
'name' => 'Polished mirrors',
]);
});
+1 -1
View File
@@ -16,7 +16,7 @@ use Tests\TestCase;
pest()->extend(TestCase::class) pest()->extend(TestCase::class)
->use(RefreshDatabase::class) ->use(RefreshDatabase::class)
->in('Feature'); ->in('Feature', 'Browser');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------