diff --git a/IDEA.md b/IDEA.md index 5e560d5..ee43763 100644 --- a/IDEA.md +++ b/IDEA.md @@ -50,7 +50,7 @@ During this session, we successfully built out and verified several core archite * Created a dynamic user detail page (`dynamics.users.show`) scoped to each dynamic. It displays a participant's role, custom display name, fallback real name, and a clean chronological listing of their 10 most recent mutations (activities) in that dynamic. 8. **Polymorphic System Message placeholders & Dynamic Client-Side Linking**: - * Refactored system log activity messages to use native `` placeholders and associated them with polymorphic `subject_id` and `subject_type` objects. + * Refactored system log activity messages to use native `` placeholders and associated them with polymorphic `subject_id` and `subject_type` objects. * On the client-side, the chat component parses these placeholders into rich, clickable links to User Profiles, and dynamically matches and wraps referenced ledger names into links pointing directly to the ledger show page. * Added backend-side placeholder resolution inside `ActivityService` for the dashboard, ensuring unread system logs translate cleanly to real names across multiple dynamics. @@ -64,4 +64,4 @@ During this session, we successfully built out and verified several core archite 11. **Standardized Policy-Driven UI Capabilities**: * Eliminated unstandardized client-side role checks and boolean flags, replacing them with structured `can` capability objects returned directly from Laravel policies. * Combined permission validation with state-based business constraints in `MutationPolicy` (e.g., suggestions can be approved/rejected only if `'pending'`; and voided only if not `'voided'`), securing both the frontend action buttons and backend controllers simultaneously. - * Achieved **65/65 passing Pest PHP tests with 333 assertions**. \ No newline at end of file + * Achieved **65/65 passing Pest PHP tests with 333 assertions**. diff --git a/app/Http/Controllers/DynamicInvitationController.php b/app/Http/Controllers/DynamicInvitationController.php index 2661b00..3e31116 100644 --- a/app/Http/Controllers/DynamicInvitationController.php +++ b/app/Http/Controllers/DynamicInvitationController.php @@ -118,7 +118,7 @@ class DynamicInvitationController extends Controller // Log to Dynamic chat activity log! $dynamic->chat->messages()->create([ 'user_id' => null, - 'content' => "user()->id}> joined the Dynamic as a ".strtoupper($invitation->role), + 'content' => "user()->uuid}> joined the Dynamic as a ".strtoupper($invitation->role), 'subject_id' => $request->user()->id, 'subject_type' => User::class, ]); diff --git a/app/Http/Controllers/MutationController.php b/app/Http/Controllers/MutationController.php index 9b1a8bc..8bf5993 100644 --- a/app/Http/Controllers/MutationController.php +++ b/app/Http/Controllers/MutationController.php @@ -19,30 +19,10 @@ class MutationController extends Controller { use AuthorizesRequests; - /** - * Display a listing of the resource. - */ - public function index() - { - // - } - - /** - * Show the form for creating a new resource. - */ - public function create() - { - // - } - - /** - * Store a newly created resource in storage. - */ public function store(StoreMutationRequest $request, Dynamic $dynamic, Ledger $ledger) { $this->authorize('create', [Mutation::class, $ledger]); - // If the user is an owner, default status to 'approved'. Otherwise default to 'pending'. $status = $request->user()->can('update', $ledger) ? 'approved' : 'pending'; $mutation = DB::transaction(function () use ($request, $ledger, $status) { @@ -70,7 +50,6 @@ class MutationController extends Controller } } - // Only increment score if the status is approved! if ($status === 'approved') { $ledger->increment('score', $request->validated('amount')); } @@ -78,48 +57,26 @@ class MutationController extends Controller return $mutation; }); - // Notify all other participants $recipients = $dynamic->participants()->where('users.id', '!=', $request->user()->id)->get(); $message = $status === 'approved' ? "{$request->user()->name} added a new entry: \"{$mutation->description}\"." : "{$request->user()->name} suggested a new entry: \"{$mutation->description}\"."; - Notification::send($recipients, new NewActivityNotification([ - 'content' => $message, - 'url' => route('dynamics.ledgers.show', [$dynamic, $ledger]), - ])); + if ($recipients->isNotEmpty()) { + Notification::send($recipients, new NewActivityNotification([ + 'content' => $message, + 'url' => route('dynamics.ledgers.show', [$dynamic, $ledger]), + ])); + } return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]); } - /** - * Display the specified resource. - */ - public function show(Dynamic $dynamic, Ledger $ledger, Mutation $mutation) - { - $this->authorize('view', $mutation); - - return $mutation; - } - - /** - * Show the form for editing the specified resource. - */ - public function edit(Dynamic $dynamic, Ledger $ledger, Mutation $mutation) - { - // - } - - /** - * Update the specified resource in storage. - */ public function update(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation) { $this->authorize('update', $mutation); - $request->validate([ - 'status' => ['required', 'string', 'in:approved,rejected'], - ]); + $request->validate(['status' => ['required', 'string', 'in:approved,rejected']]); $oldStatus = $mutation->status; $newStatus = $request->input('status'); @@ -127,7 +84,6 @@ class MutationController extends Controller DB::transaction(function () use ($mutation, $ledger, $oldStatus, $newStatus) { $mutation->update(['status' => $newStatus]); - // Adjust the ledger score if status transitions to approved or from approved! if ($oldStatus !== 'approved' && $newStatus === 'approved') { $ledger->increment('score', $mutation->amount); } elseif ($oldStatus === 'approved' && $newStatus !== 'approved') { @@ -135,41 +91,17 @@ class MutationController extends Controller } }); - // Log to Mutation and Dynamic chats $user = $request->user(); $statusText = strtoupper($newStatus); - $mutationMsg = $mutation->chat->messages()->create([ - 'user_id' => null, - 'content' => "Suggestion was {$statusText} by id}>.", - 'subject_id' => $mutation->id, - 'subject_type' => Mutation::class, - ]); - broadcast(new MessageSent($mutationMsg)); - - if ($newStatus === 'approved') { - $dynamicMsg = $dynamic->chat->messages()->create([ - 'user_id' => null, - 'content' => "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 { - $dynamicMsg = $dynamic->chat->messages()->create([ - 'user_id' => null, - 'content' => "id}> REJECTED the suggestion \"{$mutation->description}\" on \"{$ledger->name}\" ledger.", - 'subject_id' => $mutation->id, - 'subject_type' => Mutation::class, - ]); + // Notify the suggester + $suggester = $mutation->user; + if ($suggester && $suggester->id !== $user->id) { + Notification::send($suggester, new NewActivityNotification([ + 'content' => "Your suggestion \"{$mutation->description}\" was {$statusText} by {$user->name}.", + 'url' => route('dynamics.ledgers.show', [$dynamic, $ledger]), + ])); } - broadcast(new MessageSent($dynamicMsg)); - - // Notify all other participants - $recipients = $dynamic->participants()->where('users.id', '!=', $request->user()->id)->get(); - Notification::send($recipients, new NewActivityNotification([ - 'content' => "{$user->name} {$statusText} the suggestion: \"{$mutation->description}\".", - 'url' => route('dynamics.ledgers.show', [$dynamic, $ledger]), - ])); return redirect()->back(); } @@ -178,23 +110,21 @@ class MutationController extends Controller { $this->authorize('void', $mutation); - $mutation->update(['status' => 'voided']); + DB::transaction(function() use ($mutation, $ledger) { + if ($mutation->status === 'approved') { + $ledger->decrement('score', $mutation->amount); + } + $mutation->update(['status' => 'voided']); + }); - // Notify all other participants $recipients = $dynamic->participants()->where('users.id', '!=', $request->user()->id)->get(); - Notification::send($recipients, new NewActivityNotification([ - 'content' => "{$request->user()->name} voided an entry: \"{$mutation->description}\".", - 'url' => route('dynamics.ledgers.show', [$dynamic, $ledger]), - ])); + if ($recipients->isNotEmpty()) { + Notification::send($recipients, new NewActivityNotification([ + 'content' => "{$request->user()->name} voided an entry: \"{$mutation->description}\".", + 'url' => route('dynamics.ledgers.show', [$dynamic, $ledger]), + ])); + } return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]); } - - /** - * Remove the specified resource from storage. - */ - public function destroy(Dynamic $dynamic, Ledger $ledger, Mutation $mutation) - { - // - } } diff --git a/app/Models/Mutation.php b/app/Models/Mutation.php index 9f030d7..69056b6 100644 --- a/app/Models/Mutation.php +++ b/app/Models/Mutation.php @@ -70,8 +70,8 @@ class Mutation extends Model $mutationMsg = $mutation->chat->messages()->create([ 'user_id' => null, 'content' => $status === 'approved' - ? "Entry was created by id}>." - : "Suggestion was created by id}>.", + ? "Entry was created by uuid}>." + : "Suggestion was created by uuid}>.", 'subject_id' => $mutation->id, 'subject_type' => Mutation::class, ]); @@ -80,14 +80,14 @@ class Mutation extends Model if ($status === 'approved') { $dynamicMsg = $dynamic->chat->messages()->create([ 'user_id' => null, - 'content' => "id}> added entry \"{$mutation->description}\" for ".($mutation->amount >= 0 ? '+' : '')."{$mutation->amount} points on \"{$ledger->name}\" ledger.", + 'content' => "uuid}> 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' => "id}> suggested \"{$mutation->description}\" for ".($mutation->amount >= 0 ? '+' : '')."{$mutation->amount} points on \"{$ledger->name}\" ledger.", + 'content' => "uuid}> suggested \"{$mutation->description}\" for ".($mutation->amount >= 0 ? '+' : '')."{$mutation->amount} points on \"{$ledger->name}\" ledger.", 'subject_id' => $mutation->id, 'subject_type' => Mutation::class, ]); diff --git a/app/Notifications/NewActivityNotification.php b/app/Notifications/NewActivityNotification.php index 1683743..bb9950e 100644 --- a/app/Notifications/NewActivityNotification.php +++ b/app/Notifications/NewActivityNotification.php @@ -2,8 +2,6 @@ namespace App\Notifications; -use App\Models\Chat; -use App\Models\Message; use Illuminate\Bus\Queueable; use Illuminate\Notifications\Notification; use NotificationChannels\WebPush\WebPushChannel; @@ -13,47 +11,28 @@ class NewActivityNotification extends Notification { use Queueable; - public $activity; + public array $activity; - /** - * Create a new notification instance. - */ - public function __construct($activity) + public function __construct(array $activity) { $this->activity = $activity; } - /** - * Get the notification's delivery channels. - * - * @return array - */ public function via(object $notifiable): array { - return ['database', WebPushChannel::class]; + return [WebPushChannel::class, 'database']; } - /** - * Get the web push representation of the notification. - */ public function toWebPush(object $notifiable): WebPushMessage { - - $result = (new WebPushMessage) + return (new WebPushMessage) ->title('New Activity') ->icon('/apple-touch-icon.png') ->body($this->activity['content']) ->action('View', 'view') ->data(['url' => $this->activity['url']]); - - return $result; } - /** - * Get the array representation of the notification. - * - * @return array - */ public function toArray(object $notifiable): array { return [ diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php index 95e220c..7e6e468 100644 --- a/app/Services/ActivityService.php +++ b/app/Services/ActivityService.php @@ -100,7 +100,7 @@ class ActivityService $participants = $dynamic->participants()->withPivot('display_name')->get(); $participantsMap = $participants->reduce(function ($acc, $p) { - $acc[$p->id] = $p->pivot->display_name ?? $p->name; + $acc[$p->uuid] = $p->pivot->display_name ?? $p->name; return $acc; }, []); @@ -115,7 +115,7 @@ class ActivityService $messageData['url'] = $this->getUrlForMessage($message); // Resolve placeholders to actual names/display names - $messageData['content'] = preg_replace_callback('//', function ($matches) use ($participantsMap) { + $messageData['content'] = preg_replace_callback('//', function ($matches) use ($participantsMap) { $userId = $matches[1]; return $participantsMap[$userId] ?? "User #{$userId}"; diff --git a/database/migrations/2026_07_06_143909_migrate_chat_messages_to_uuid_base.php b/database/migrations/2026_07_06_143909_migrate_chat_messages_to_uuid_base.php new file mode 100644 index 0000000..1819d32 --- /dev/null +++ b/database/migrations/2026_07_06_143909_migrate_chat_messages_to_uuid_base.php @@ -0,0 +1,42 @@ +each(function (Message $message) { + $msg = $message->content; + + $msg = preg_replace_callback('//', function ($matches) { + $userId = $matches[1]; + $user = \App\Models\User::find($userId); + if($user){ + $userId = $user->uuid; + } + return ""; + }, $msg); + + if($msg != $message->content) { + $message->update(['content' => $msg]); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('uuid_base', function (Blueprint $table) { + // + }); + } +}; diff --git a/resources/js/components/Chat.vue b/resources/js/components/Chat.vue index ebf8c63..f281e75 100644 --- a/resources/js/components/Chat.vue +++ b/resources/js/components/Chat.vue @@ -149,7 +149,7 @@ const participantsById = computed(() => { {} as Record< number, { - id: number; + id: string; name: string; pivot?: { display_name: string | null } | null; } diff --git a/resources/js/components/ChatMessage.vue b/resources/js/components/ChatMessage.vue index 82e4e71..b26b4a1 100644 --- a/resources/js/components/ChatMessage.vue +++ b/resources/js/components/ChatMessage.vue @@ -19,10 +19,13 @@ const props = defineProps<{ }>(); const processedContent = computed(() => { - return props.message.content.replace(//g, (match, userId) => { - // This is a placeholder for a more robust user lookup - return `@user${userId}`; - }); + return props.message.content.replace( + //g, + (match, userId) => { + // This is a placeholder for a more robust user lookup + return `@user${userId}`; + }, + ); }); diff --git a/resources/js/components/chat/ChatSystemMessage.vue b/resources/js/components/chat/ChatSystemMessage.vue index 323ee76..5dc63ed 100644 --- a/resources/js/components/chat/ChatSystemMessage.vue +++ b/resources/js/components/chat/ChatSystemMessage.vue @@ -39,11 +39,11 @@ const parsedContent = computed(() => { let content = props.message.content; // 1. Replace placeholders with links to their dynamic profile - const userRegex = //g; + const userRegex = //g; content = content.replace(userRegex, (match, userId) => { - const user = props.participantsById[Number(userId)]; + const user = props.participantsById[(userId)]; if (user) { - const url = route('dynamics.users.show', [props.dynamicId, Number(userId)]); + const url = route('dynamics.users.show', [props.dynamicId, (userId)]); return `${ user.pivot?.display_name ?? user.name }`; @@ -77,7 +77,7 @@ const parsedContent = computed(() => { /[-\/\\^$*+?.()|[\]{}]/g, '\\$&', ); - + const nameRegex = new RegExp(`"${escapedName}"`, 'g'); content = content.replace( nameRegex, diff --git a/resources/js/components/chat/ChatUserMessage.vue b/resources/js/components/chat/ChatUserMessage.vue index 97477ae..611e2ff 100644 --- a/resources/js/components/chat/ChatUserMessage.vue +++ b/resources/js/components/chat/ChatUserMessage.vue @@ -49,15 +49,21 @@ const parsedContent = computed(() => { let content = props.message.content; // 1. Replace placeholders with links to their dynamic profile - const userRegex = //g; + const userRegex = //g; content = content.replace(userRegex, (match, userId) => { - const user = props.participantsById[Number(userId)]; + const user = props.participantsById[(userId)]; + if (user) { - const url = route('dynamics.users.show', [props.dynamicId, Number(userId)]); + const url = route('dynamics.users.show', [ + props.dynamicId, + (userId), + ]); + return `${ user.pivot?.display_name ?? user.name }`; } + return `User #${userId}`; }); @@ -87,7 +93,7 @@ const parsedContent = computed(() => { /[-\/\\^$*+?.()|[\]{}]/g, '\\$&', ); - + const nameRegex = new RegExp(`"${escapedName}"`, 'g'); content = content.replace( nameRegex, @@ -104,9 +110,7 @@ const parsedContent = computed(() => {