From 5ce63ca63648b7f3c6ac1c7f5f886d4a5e779f4c Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Fri, 10 Apr 2026 06:46:45 -0400 Subject: [PATCH 1/4] feat(ideas): add private draft crud --- app/Http/Controllers/IdeaController.php | 88 +++++++++++++++++ app/Policies/IdeaPolicy.php | 65 +++++++++++++ app/Providers/AppServiceProvider.php | 4 + .../views/components/ui/button.blade.php | 19 ++-- .../views/components/ui/textarea.blade.php | 3 +- resources/views/ideas/_form.blade.php | 24 +++++ resources/views/ideas/create.blade.php | 82 ++++++++++++++++ resources/views/ideas/edit.blade.php | 96 +++++++++++++++++++ resources/views/ideas/index.blade.php | 17 +++- routes/web.php | 11 +-- tests/Feature/Ideas/IdeasDraftCrudTest.php | 85 ++++++++++++++++ 11 files changed, 474 insertions(+), 20 deletions(-) create mode 100644 app/Http/Controllers/IdeaController.php create mode 100644 app/Policies/IdeaPolicy.php create mode 100644 resources/views/ideas/_form.blade.php create mode 100644 resources/views/ideas/create.blade.php create mode 100644 resources/views/ideas/edit.blade.php create mode 100644 tests/Feature/Ideas/IdeasDraftCrudTest.php diff --git a/app/Http/Controllers/IdeaController.php b/app/Http/Controllers/IdeaController.php new file mode 100644 index 0000000..5b542f6 --- /dev/null +++ b/app/Http/Controllers/IdeaController.php @@ -0,0 +1,88 @@ +user() + ->ideas() + ->latest('updated_at') + ->latest('id') + ->get(); + + return view('ideas.index', [ + 'ideas' => $ideas, + ]); + } + + public function create(): View + { + Gate::authorize('create', Idea::class); + + return view('ideas.create'); + } + + public function store(Request $request): RedirectResponse + { + Gate::authorize('create', Idea::class); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'summary' => ['required', 'string', 'max:500'], + 'details' => ['required', 'string'], + ]); + + $idea = $request->user()->ideas()->create($validated); + + return redirect() + ->route('ideas.edit', $idea) + ->with('status', 'Idea draft created.'); + } + + public function edit(Idea $idea): View + { + Gate::authorize('update', $idea); + + return view('ideas.edit', [ + 'idea' => $idea, + ]); + } + + public function update(Request $request, Idea $idea): RedirectResponse + { + Gate::authorize('update', $idea); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'summary' => ['required', 'string', 'max:500'], + 'details' => ['required', 'string'], + ]); + + $idea->update($validated); + + return redirect() + ->route('ideas.edit', $idea) + ->with('status', 'Idea draft updated.'); + } + + public function destroy(Idea $idea): RedirectResponse + { + Gate::authorize('delete', $idea); + + $idea->delete(); + + return redirect() + ->route('ideas.index') + ->with('status', 'Idea draft deleted.'); + } +} diff --git a/app/Policies/IdeaPolicy.php b/app/Policies/IdeaPolicy.php new file mode 100644 index 0000000..64d66a2 --- /dev/null +++ b/app/Policies/IdeaPolicy.php @@ -0,0 +1,65 @@ +user->is($user); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Idea $idea): bool + { + return $idea->user->is($user); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Idea $idea): bool + { + return $idea->user->is($user); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Idea $idea): bool + { + return $idea->user->is($user); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Idea $idea): bool + { + return $idea->user->is($user); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c07953a..7075704 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,9 @@ namespace App\Providers; +use App\Models\Idea; use App\Models\User; +use App\Policies\IdeaPolicy; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; @@ -21,6 +23,8 @@ public function register(): void */ public function boot(): void { + Gate::policy(Idea::class, IdeaPolicy::class); + Gate::before(function (User $user): ?bool { return $user->isAn('admin') ? true : null; }); diff --git a/resources/views/components/ui/button.blade.php b/resources/views/components/ui/button.blade.php index a55b908..e1e0c68 100644 --- a/resources/views/components/ui/button.blade.php +++ b/resources/views/components/ui/button.blade.php @@ -1,6 +1,7 @@ @props([ 'type' => 'button', 'variant' => 'primary', + 'as' => 'button', ]) @php @@ -13,9 +14,15 @@ $baseClasses = 'inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50'; @endphp - +@if ($as === 'a') + class([$baseClasses, $variantClasses[$variant] ?? $variantClasses['primary']]) }}> + {{ $slot }} + +@else + +@endif diff --git a/resources/views/components/ui/textarea.blade.php b/resources/views/components/ui/textarea.blade.php index eee6879..4e46c85 100644 --- a/resources/views/components/ui/textarea.blade.php +++ b/resources/views/components/ui/textarea.blade.php @@ -1,6 +1,7 @@ @props([ 'label' => null, 'error' => null, + 'value' => null, ]) @php @@ -12,7 +13,7 @@ {{ $label }} @endif - + @if ($error) {{ $error }} diff --git a/resources/views/ideas/_form.blade.php b/resources/views/ideas/_form.blade.php new file mode 100644 index 0000000..f8607a1 --- /dev/null +++ b/resources/views/ideas/_form.blade.php @@ -0,0 +1,24 @@ +@php + $idea ??= null; +@endphp + +
+ @csrf + @isset($method) + @method($method) + @endisset + + + + + +
+ {{ $submitLabel }} + + @if ($cancelHref) + + Cancel + + @endif +
+ diff --git a/resources/views/ideas/create.blade.php b/resources/views/ideas/create.blade.php new file mode 100644 index 0000000..4224794 --- /dev/null +++ b/resources/views/ideas/create.blade.php @@ -0,0 +1,82 @@ +@php + $toolbarItems = [ + [ + 'label' => 'Ideas', + 'href' => route('ideas.index'), + 'current' => true, + 'icon' => 'heroicon-o-light-bulb', + ], + [ + 'label' => 'Planning', + 'href' => route('planning.index'), + 'icon' => 'heroicon-o-clipboard-document-list', + ], + [ + 'label' => 'Development', + 'href' => route('development.index'), + 'icon' => 'heroicon-o-code-bracket-square', + ], + [ + 'label' => 'Testing', + 'href' => route('testing.index'), + 'icon' => 'heroicon-o-beaker', + ], + [ + 'label' => 'Security', + 'href' => route('security.index'), + 'icon' => 'heroicon-o-shield-check', + ], + [ + 'label' => 'Ops', + 'href' => route('ops.index'), + 'icon' => 'heroicon-o-command-line', + ], + ]; + + $contextItems = [ + [ + 'label' => 'Inbox', + 'href' => route('ideas.index'), + 'icon' => 'heroicon-o-inbox-stack', + ], + [ + 'label' => 'Drafts', + 'href' => route('ideas.index'), + 'current' => true, + 'icon' => 'heroicon-o-pencil-square', + ], + [ + 'label' => 'Shared', + 'href' => route('ideas.index'), + 'icon' => 'heroicon-o-users', + ], + [ + 'label' => 'Proposals', + 'href' => route('ideas.index'), + 'icon' => 'heroicon-o-rocket-launch', + ], + ]; +@endphp + + +
+ + + + @include('ideas._form', [ + 'action' => route('ideas.store'), + 'method' => null, + 'submitLabel' => 'Save draft', + 'cancelHref' => route('ideas.index'), + ]) + +
+
diff --git a/resources/views/ideas/edit.blade.php b/resources/views/ideas/edit.blade.php new file mode 100644 index 0000000..469ff2e --- /dev/null +++ b/resources/views/ideas/edit.blade.php @@ -0,0 +1,96 @@ +@php + $toolbarItems = [ + [ + 'label' => 'Ideas', + 'href' => route('ideas.index'), + 'current' => true, + 'icon' => 'heroicon-o-light-bulb', + ], + [ + 'label' => 'Planning', + 'href' => route('planning.index'), + 'icon' => 'heroicon-o-clipboard-document-list', + ], + [ + 'label' => 'Development', + 'href' => route('development.index'), + 'icon' => 'heroicon-o-code-bracket-square', + ], + [ + 'label' => 'Testing', + 'href' => route('testing.index'), + 'icon' => 'heroicon-o-beaker', + ], + [ + 'label' => 'Security', + 'href' => route('security.index'), + 'icon' => 'heroicon-o-shield-check', + ], + [ + 'label' => 'Ops', + 'href' => route('ops.index'), + 'icon' => 'heroicon-o-command-line', + ], + ]; + + $contextItems = [ + [ + 'label' => 'Inbox', + 'href' => route('ideas.index'), + 'icon' => 'heroicon-o-inbox-stack', + ], + [ + 'label' => 'Drafts', + 'href' => route('ideas.index'), + 'current' => true, + 'icon' => 'heroicon-o-pencil-square', + ], + [ + 'label' => 'Shared', + 'href' => route('ideas.index'), + 'icon' => 'heroicon-o-users', + ], + [ + 'label' => 'Proposals', + 'href' => route('ideas.index'), + 'icon' => 'heroicon-o-rocket-launch', + ], + ]; +@endphp + + +
+ + + + @include('ideas._form', [ + 'action' => route('ideas.update', $idea), + 'method' => 'PUT', + 'submitLabel' => 'Update draft', + 'cancelHref' => route('ideas.index'), + 'idea' => $idea, + ]) + + + +
+ @csrf + @method('DELETE') +

+ Deleting removes the private draft and its notes. +

+
+ Delete draft +
+
+
+
+
diff --git a/resources/views/ideas/index.blade.php b/resources/views/ideas/index.blade.php index 6d7784b..1ef925b 100644 --- a/resources/views/ideas/index.blade.php +++ b/resources/views/ideas/index.blade.php @@ -68,7 +68,11 @@ eyebrow="Ideas" title="Capture rough thoughts before they become projects." description="This area is for quick notes, private drafting, and later proposal work." - /> + > + + New idea + +
@@ -80,9 +84,14 @@

{{ $idea->title }}

{{ $idea->summary }}

- - {{ $idea->shared_at ? 'Shared' : 'Private' }} - +
+ + {{ $idea->shared_at ? 'Shared' : 'Private' }} + + + Edit + +
@empty diff --git a/routes/web.php b/routes/web.php index 7bf9188..c847e6c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,7 +2,7 @@ use App\Http\Controllers\Admin\ImpersonationController; use App\Http\Controllers\Admin\UserController; -use App\Models\Idea; +use App\Http\Controllers\IdeaController; use Illuminate\Support\Facades\Route; Route::get('/', function () { @@ -10,14 +10,7 @@ })->name('home'); Route::middleware(['auth'])->group(function (): void { - Route::get('/ideas', function () { - return view('ideas.index', [ - 'ideas' => Idea::query() - ->whereBelongsTo(auth()->user()) - ->orderByDesc('updated_at') - ->get(), - ]); - })->name('ideas.index'); + Route::resource('ideas', IdeaController::class)->except(['show']); }); Route::view('/planning', 'section-placeholder', ['title' => 'Planning'])->name('planning.index'); diff --git a/tests/Feature/Ideas/IdeasDraftCrudTest.php b/tests/Feature/Ideas/IdeasDraftCrudTest.php new file mode 100644 index 0000000..54855d8 --- /dev/null +++ b/tests/Feature/Ideas/IdeasDraftCrudTest.php @@ -0,0 +1,85 @@ +create(); + + $this->actingAs($user) + ->post(route('ideas.store'), [ + 'title' => 'Reduce proposal friction', + 'summary' => 'Capture rough thoughts before discussion.', + 'details' => 'We need a private place to work through scope before sharing.', + ]) + ->assertRedirect(); + + $idea = Idea::query()->sole(); + + expect($idea->user->is($user))->toBeTrue() + ->and($idea->shared_at)->toBeNull() + ->and($idea->title)->toBe('Reduce proposal friction'); +}); + +it('lets the owner edit and update an idea draft', function (): void { + $user = User::factory()->create(); + $idea = Idea::factory()->for($user)->create([ + 'title' => 'Capture smaller ideas', + 'summary' => 'Keep a draft queue.', + 'details' => 'Drafts should stay private until they are ready.', + ]); + + $this->actingAs($user) + ->get(route('ideas.edit', $idea)) + ->assertSuccessful() + ->assertSee('Capture smaller ideas', false); + + $this->actingAs($user) + ->put(route('ideas.update', $idea), [ + 'title' => 'Capture sharper ideas', + 'summary' => 'Keep a private draft queue.', + 'details' => 'Drafts should stay private until the author is ready.', + ]) + ->assertRedirect(route('ideas.edit', $idea)); + + $idea->refresh(); + + expect($idea->title)->toBe('Capture sharper ideas') + ->and($idea->summary)->toBe('Keep a private draft queue.'); +}); + +it('prevents another user from editing or deleting an idea draft', function (): void { + $owner = User::factory()->create(); + $intruder = User::factory()->create(); + $idea = Idea::factory()->for($owner)->create(); + + $this->actingAs($intruder) + ->get(route('ideas.edit', $idea)) + ->assertForbidden(); + + $this->actingAs($intruder) + ->put(route('ideas.update', $idea), [ + 'title' => 'Unauthorized change', + 'summary' => 'Should be blocked.', + 'details' => 'This request should not pass.', + ]) + ->assertForbidden(); + + $this->actingAs($intruder) + ->delete(route('ideas.destroy', $idea)) + ->assertForbidden(); +}); + +it('allows the owner to delete a draft', function (): void { + $user = User::factory()->create(); + $idea = Idea::factory()->for($user)->create(); + + $this->actingAs($user) + ->delete(route('ideas.destroy', $idea)) + ->assertRedirect(route('ideas.index')); + + expect(Idea::query()->count())->toBe(0); +}); From ce6048b6177dbffadbd5ff653af2c1ea1d69a5e4 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Fri, 10 Apr 2026 06:56:20 -0400 Subject: [PATCH 2/4] feat(ideas): add ai refinement --- app/Ai/Agents/Ideas/IdeaRefinementAgent.php | 75 ++++++++++ app/Http/Controllers/IdeaController.php | 10 ++ app/Services/Ideas/IdeaRefinementService.php | 43 ++++++ composer.json | 1 + composer.lock | 147 ++++++++++++++++++- config/ai.php | 19 +++ resources/views/ideas/edit.blade.php | 37 +++++ routes/web.php | 1 + tests/Feature/Ideas/IdeaRefinementTest.php | 78 ++++++++++ 9 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 app/Ai/Agents/Ideas/IdeaRefinementAgent.php create mode 100644 app/Services/Ideas/IdeaRefinementService.php create mode 100644 config/ai.php create mode 100644 tests/Feature/Ideas/IdeaRefinementTest.php diff --git a/app/Ai/Agents/Ideas/IdeaRefinementAgent.php b/app/Ai/Agents/Ideas/IdeaRefinementAgent.php new file mode 100644 index 0000000..b0c1e9c --- /dev/null +++ b/app/Ai/Agents/Ideas/IdeaRefinementAgent.php @@ -0,0 +1,75 @@ + $schema->string()->required(), + 'users' => $schema->string()->required(), + 'outcomes' => $schema->string()->required(), + 'scope_gaps' => $schema->string()->required(), + 'follow_up_questions' => $schema->string()->required(), + ]; + } +} diff --git a/app/Http/Controllers/IdeaController.php b/app/Http/Controllers/IdeaController.php index 5b542f6..50c928d 100644 --- a/app/Http/Controllers/IdeaController.php +++ b/app/Http/Controllers/IdeaController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Idea; +use App\Services\Ideas\IdeaRefinementService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; @@ -85,4 +86,13 @@ public function destroy(Idea $idea): RedirectResponse ->route('ideas.index') ->with('status', 'Idea draft deleted.'); } + + public function refine(Idea $idea, IdeaRefinementService $service): RedirectResponse + { + Gate::authorize('update', $idea); + + return redirect() + ->route('ideas.edit', $idea) + ->with('ideaRefinement', $service->refine($idea)); + } } diff --git a/app/Services/Ideas/IdeaRefinementService.php b/app/Services/Ideas/IdeaRefinementService.php new file mode 100644 index 0000000..0ae490a --- /dev/null +++ b/app/Services/Ideas/IdeaRefinementService.php @@ -0,0 +1,43 @@ +title, + 'Summary: '.$idea->summary, + 'Details: '.$idea->details, + ])); + + $response = IdeaRefinementAgent::make()->prompt( + $prompt, + provider: 'ollama', + model: config('ai.providers.ollama.model') + ); + + return Arr::only($response->toArray(), [ + 'problem', + 'users', + 'outcomes', + 'scope_gaps', + 'follow_up_questions', + ]); + } +} diff --git a/composer.json b/composer.json index c3479a5..1489cf0 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.3", "blade-ui-kit/blade-heroicons": "^2.7", + "laravel/ai": "^0.5.0", "laravel/fortify": "^1.36", "laravel/framework": "^13.0", "laravel/tinker": "^3.0", diff --git a/composer.lock b/composer.lock index d0bc6e5..a8569cc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ff5fcc82ac87a052082e83c531ec2264", + "content-hash": "2446a6850d569780b52c60575e386ed1", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1308,6 +1308,72 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "laravel/ai", + "version": "v0.5.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/ai.git", + "reference": "85f66caccc48da551cda9ace01cd7b70092e45a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/ai/zipball/85f66caccc48da551cda9ace01cd7b70092e45a7", + "reference": "85f66caccc48da551cda9ace01cd7b70092e45a7", + "shasum": "" + }, + "require": { + "illuminate/console": "^12.0|^13.0", + "illuminate/container": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/filesystem": "^12.0|^13.0", + "illuminate/json-schema": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "laravel/prompts": "^0.3.6", + "laravel/serializable-closure": "^2.0", + "php": "^8.3", + "prism-php/prism": "^0.100.0" + }, + "require-dev": { + "laravel/pint": "^1.26", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^10.6|^11.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Ai\\AiServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php" + ], + "psr-4": { + "Laravel\\Ai\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The official AI SDK for Laravel.", + "homepage": "https://github.com/laravel/ai", + "keywords": [ + "ai", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/ai/issues", + "source": "https://github.com/laravel/ai" + }, + "time": "2026-04-09T21:22:35+00:00" + }, { "name": "laravel/fortify", "version": "v1.36.2", @@ -3124,6 +3190,85 @@ }, "time": "2025-09-19T22:51:08+00:00" }, + { + "name": "prism-php/prism", + "version": "v0.100.1", + "source": { + "type": "git", + "url": "https://github.com/prism-php/prism.git", + "reference": "5d6cc65b80b19cf3f22744703ac0c727b68cdca8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/prism-php/prism/zipball/5d6cc65b80b19cf3f22744703ac0c727b68cdca8", + "reference": "5d6cc65b80b19cf3f22744703ac0c727b68cdca8", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "laravel/framework": "^11.0|^12.0|^13.0", + "php": "^8.2" + }, + "require-dev": { + "brianium/paratest": "^7.8.4", + "laravel/mcp": "^0.6.0", + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9|^10|^11", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-arch": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpdoc-parser": "^2.0", + "phpstan/phpstan": "2.1.34", + "phpstan/phpstan-deprecation-rules": "^2.0", + "projektgopher/whisky": "^0.7.0", + "rector/rector": "2.3.3", + "spatie/laravel-ray": "^1.39", + "symplify/rule-doc-generator-contracts": "^11.2" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PrismServer": "Prism\\Prism\\Facades\\PrismServer" + }, + "providers": [ + "Prism\\Prism\\PrismServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Prism\\Prism\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "TJ Miller", + "email": "hello@echolabs.dev" + } + ], + "description": "A powerful Laravel package for integrating Large Language Models (LLMs) into your applications.", + "support": { + "issues": "https://github.com/prism-php/prism/issues", + "source": "https://github.com/prism-php/prism/tree/v0.100.1" + }, + "funding": [ + { + "url": "https://github.com/sixlive", + "type": "github" + } + ], + "time": "2026-03-20T20:37:17+00:00" + }, { "name": "psr/clock", "version": "1.0.0", diff --git a/config/ai.php b/config/ai.php new file mode 100644 index 0000000..414af72 --- /dev/null +++ b/config/ai.php @@ -0,0 +1,19 @@ + env('AI_DEFAULT_PROVIDER', 'ollama'), + 'default_for_images' => env('AI_DEFAULT_FOR_IMAGES', 'ollama'), + 'default_for_audio' => env('AI_DEFAULT_FOR_AUDIO', 'ollama'), + 'default_for_transcription' => env('AI_DEFAULT_FOR_TRANSCRIPTION', 'ollama'), + 'default_for_embeddings' => env('AI_DEFAULT_FOR_EMBEDDINGS', 'ollama'), + 'default_for_reranking' => env('AI_DEFAULT_FOR_RERANKING', 'ollama'), + + 'providers' => [ + 'ollama' => [ + 'driver' => 'ollama', + 'key' => env('OLLAMA_API_KEY', ''), + 'url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'), + 'model' => env('OLLAMA_MODEL', 'llama3.1'), + ], + ], +]; diff --git a/resources/views/ideas/edit.blade.php b/resources/views/ideas/edit.blade.php index 469ff2e..4134f21 100644 --- a/resources/views/ideas/edit.blade.php +++ b/resources/views/ideas/edit.blade.php @@ -78,8 +78,45 @@ 'cancelHref' => route('ideas.index'), 'idea' => $idea, ]) +
+

+ Ask the local model to refine the current draft context. +

+
+ @csrf + Refine with AI +
+
+ @if (session()->has('ideaRefinement')) + @php($ideaRefinement = session('ideaRefinement')) + +
+
+

Problem

+

{{ $ideaRefinement['problem'] }}

+
+
+

Users

+

{{ $ideaRefinement['users'] }}

+
+
+

Outcomes

+

{{ $ideaRefinement['outcomes'] }}

+
+
+

Scope gaps

+

{{ $ideaRefinement['scope_gaps'] }}

+
+
+
+

Follow-up questions

+

{{ $ideaRefinement['follow_up_questions'] }}

+
+
+ @endif +
@csrf diff --git a/routes/web.php b/routes/web.php index c847e6c..f7a4a38 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ Route::middleware(['auth'])->group(function (): void { Route::resource('ideas', IdeaController::class)->except(['show']); + Route::post('/ideas/{idea}/refine', [IdeaController::class, 'refine'])->name('ideas.refine'); }); Route::view('/planning', 'section-placeholder', ['title' => 'Planning'])->name('planning.index'); diff --git a/tests/Feature/Ideas/IdeaRefinementTest.php b/tests/Feature/Ideas/IdeaRefinementTest.php new file mode 100644 index 0000000..0842631 --- /dev/null +++ b/tests/Feature/Ideas/IdeaRefinementTest.php @@ -0,0 +1,78 @@ +create(); + $idea = Idea::factory()->for($user)->create([ + 'title' => 'Reduce proposal friction', + 'summary' => 'Help the team shape rough thoughts.', + 'details' => 'Give the owner a way to sharpen an idea before sharing it.', + ]); + + Ai::fakeAgent(IdeaRefinementAgent::class, [[ + 'problem' => 'The team needs a faster way to shape raw ideas.', + 'users' => 'Product and engineering leads.', + 'outcomes' => 'Clearer proposals and less back-and-forth.', + 'scope_gaps' => 'No explicit decision criteria yet.', + 'follow_up_questions' => 'What approval threshold should apply?', + ]]); + + $service = app(IdeaRefinementService::class); + $refinement = $service->refine($idea); + + expect($refinement)->toMatchArray([ + 'problem' => 'The team needs a faster way to shape raw ideas.', + 'users' => 'Product and engineering leads.', + 'outcomes' => 'Clearer proposals and less back-and-forth.', + 'scope_gaps' => 'No explicit decision criteria yet.', + 'follow_up_questions' => 'What approval threshold should apply?', + ]); + + Ai::assertAgentWasPrompted( + IdeaRefinementAgent::class, + fn ($prompt): bool => str_contains($prompt->prompt, $idea->title) + && str_contains($prompt->prompt, $idea->summary) + && str_contains($prompt->prompt, $idea->details) + ); +}); + +it('returns refinement data to the owner on the edit screen', function (): void { + $user = User::factory()->create(); + $idea = Idea::factory()->for($user)->create(); + + $this->instance(IdeaRefinementService::class, mock(IdeaRefinementService::class, function ($mock): void { + $mock->shouldReceive('refine') + ->once() + ->andReturn([ + 'problem' => 'The team needs a faster way to shape raw ideas.', + 'users' => 'Product and engineering leads.', + 'outcomes' => 'Clearer proposals and less back-and-forth.', + 'scope_gaps' => 'No explicit decision criteria yet.', + 'follow_up_questions' => 'What approval threshold should apply?', + ]); + })); + + $this->actingAs($user) + ->post(route('ideas.refine', $idea)) + ->assertRedirect(route('ideas.edit', $idea)); + + $this->withSession([ + 'ideaRefinement' => [ + 'problem' => 'The team needs a faster way to shape raw ideas.', + 'users' => 'Product and engineering leads.', + 'outcomes' => 'Clearer proposals and less back-and-forth.', + 'scope_gaps' => 'No explicit decision criteria yet.', + 'follow_up_questions' => 'What approval threshold should apply?', + ], + ])->get(route('ideas.edit', $idea)) + ->assertSee('AI refinement', false) + ->assertSee('The team needs a faster way to shape raw ideas.', false); +}); From 18194bf8e36f670209d26b1eaf8472b413009571 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Fri, 10 Apr 2026 07:06:42 -0400 Subject: [PATCH 3/4] feat(ideas): propose drafts as projects --- app/Http/Controllers/IdeaController.php | 21 ++++++++++ app/Models/Idea.php | 6 +++ app/Models/Project.php | 33 ++++++++++++++++ database/factories/ProjectFactory.php | 32 +++++++++++++++ ...026_04_10_110550_create_projects_table.php | 36 +++++++++++++++++ resources/views/ideas/edit.blade.php | 23 +++++++++-- routes/web.php | 1 + tests/Feature/Ideas/IdeaProposalTest.php | 39 +++++++++++++++++++ 8 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 app/Models/Project.php create mode 100644 database/factories/ProjectFactory.php create mode 100644 database/migrations/2026_04_10_110550_create_projects_table.php create mode 100644 tests/Feature/Ideas/IdeaProposalTest.php diff --git a/app/Http/Controllers/IdeaController.php b/app/Http/Controllers/IdeaController.php index 50c928d..c4e6fc3 100644 --- a/app/Http/Controllers/IdeaController.php +++ b/app/Http/Controllers/IdeaController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Idea; +use App\Models\Project; use App\Services\Ideas\IdeaRefinementService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -95,4 +96,24 @@ public function refine(Idea $idea, IdeaRefinementService $service): RedirectResp ->route('ideas.edit', $idea) ->with('ideaRefinement', $service->refine($idea)); } + + public function propose(Idea $idea): RedirectResponse + { + Gate::authorize('update', $idea); + + $project = Project::query()->create([ + 'idea_id' => $idea->id, + 'user_id' => $idea->user_id, + 'title' => $idea->title, + 'summary' => $idea->summary, + 'details' => $idea->details, + 'status' => 'proposed', + 'proposed_at' => now(), + ]); + + return redirect() + ->route('ideas.edit', $idea) + ->with('status', 'Idea proposed as a project.') + ->with('proposedProjectId', $project->id); + } } diff --git a/app/Models/Idea.php b/app/Models/Idea.php index 54c948b..291abdf 100644 --- a/app/Models/Idea.php +++ b/app/Models/Idea.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; #[Fillable(['user_id', 'title', 'summary', 'details', 'shared_at'])] class Idea extends Model @@ -25,4 +26,9 @@ public function user(): BelongsTo { return $this->belongsTo(User::class); } + + public function project(): HasOne + { + return $this->hasOne(Project::class); + } } diff --git a/app/Models/Project.php b/app/Models/Project.php new file mode 100644 index 0000000..6f9d195 --- /dev/null +++ b/app/Models/Project.php @@ -0,0 +1,33 @@ + */ + use HasFactory; + + protected function casts(): array + { + return [ + 'proposed_at' => 'datetime', + ]; + } + + public function idea(): BelongsTo + { + return $this->belongsTo(Idea::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php new file mode 100644 index 0000000..26e4b1a --- /dev/null +++ b/database/factories/ProjectFactory.php @@ -0,0 +1,32 @@ + + */ +class ProjectFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'idea_id' => Idea::factory(), + 'user_id' => User::factory(), + 'title' => fake()->sentence(4), + 'summary' => fake()->sentence(), + 'details' => fake()->paragraphs(2, true), + 'status' => 'proposed', + 'proposed_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_04_10_110550_create_projects_table.php b/database/migrations/2026_04_10_110550_create_projects_table.php new file mode 100644 index 0000000..fa087dd --- /dev/null +++ b/database/migrations/2026_04_10_110550_create_projects_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('idea_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('summary'); + $table->text('details'); + $table->string('status')->default('proposed'); + $table->timestamp('proposed_at')->nullable(); + $table->timestamps(); + + $table->index(['idea_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('projects'); + } +}; diff --git a/resources/views/ideas/edit.blade.php b/resources/views/ideas/edit.blade.php index 4134f21..bc2289f 100644 --- a/resources/views/ideas/edit.blade.php +++ b/resources/views/ideas/edit.blade.php @@ -82,13 +82,28 @@

Ask the local model to refine the current draft context.

- - @csrf - Refine with AI - +
+
+ @csrf + Refine with AI +
+ +
+ @csrf + Propose project +
+
+ @if (session('proposedProjectId')) + +

+ This draft was promoted to a proposed project and linked back to the source idea. +

+
+ @endif + @if (session()->has('ideaRefinement')) @php($ideaRefinement = session('ideaRefinement')) diff --git a/routes/web.php b/routes/web.php index f7a4a38..fa0bd40 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,6 +12,7 @@ Route::middleware(['auth'])->group(function (): void { Route::resource('ideas', IdeaController::class)->except(['show']); Route::post('/ideas/{idea}/refine', [IdeaController::class, 'refine'])->name('ideas.refine'); + Route::post('/ideas/{idea}/propose', [IdeaController::class, 'propose'])->name('ideas.propose'); }); Route::view('/planning', 'section-placeholder', ['title' => 'Planning'])->name('planning.index'); diff --git a/tests/Feature/Ideas/IdeaProposalTest.php b/tests/Feature/Ideas/IdeaProposalTest.php new file mode 100644 index 0000000..0915f99 --- /dev/null +++ b/tests/Feature/Ideas/IdeaProposalTest.php @@ -0,0 +1,39 @@ +create(); + $idea = Idea::factory()->for($user)->create([ + 'title' => 'Reduce proposal friction', + 'summary' => 'Help the team shape rough thoughts.', + 'details' => 'Give the owner a way to sharpen an idea before sharing it.', + ]); + + $this->actingAs($user) + ->post(route('ideas.propose', $idea)) + ->assertRedirect(route('ideas.edit', $idea)); + + $project = Project::query()->sole(); + + expect($project->idea->is($idea))->toBeTrue() + ->and($project->user->is($user))->toBeTrue() + ->and($project->status)->toBe('proposed') + ->and($project->title)->toBe($idea->title) + ->and($project->summary)->toBe($idea->summary); +}); + +it("prevents another user from proposing someone else's draft", function (): void { + $owner = User::factory()->create(); + $intruder = User::factory()->create(); + $idea = Idea::factory()->for($owner)->create(); + + $this->actingAs($intruder) + ->post(route('ideas.propose', $idea)) + ->assertForbidden(); +}); From c98be95613c76c1fa3da191c9ae6d697e5d00d09 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Fri, 10 Apr 2026 07:20:58 -0400 Subject: [PATCH 4/4] feat(admin): add in-app notifications --- .../Admin/NotificationController.php | 81 +++++++++++++ app/Notifications/InAppNotification.php | 31 +++++ ...4_10_111853_create_notifications_table.php | 31 +++++ resources/views/admin/dashboard.blade.php | 5 + .../views/admin/notifications/index.blade.php | 106 ++++++++++++++++++ resources/views/notifications/index.blade.php | 86 ++++++++++++++ routes/web.php | 8 ++ tests/Feature/Admin/NotificationsTest.php | 77 +++++++++++++ tests/Feature/Notifications/InboxTest.php | 54 +++++++++ 9 files changed, 479 insertions(+) create mode 100644 app/Http/Controllers/Admin/NotificationController.php create mode 100644 app/Notifications/InAppNotification.php create mode 100644 database/migrations/2026_04_10_111853_create_notifications_table.php create mode 100644 resources/views/admin/notifications/index.blade.php create mode 100644 resources/views/notifications/index.blade.php create mode 100644 tests/Feature/Admin/NotificationsTest.php create mode 100644 tests/Feature/Notifications/InboxTest.php diff --git a/app/Http/Controllers/Admin/NotificationController.php b/app/Http/Controllers/Admin/NotificationController.php new file mode 100644 index 0000000..f58e7f5 --- /dev/null +++ b/app/Http/Controllers/Admin/NotificationController.php @@ -0,0 +1,81 @@ +user()->isAn('admin')) { + return view('admin.notifications.index', [ + 'users' => User::query()->orderBy('first_name')->get(), + 'notifications' => $request->user()->notifications()->latest()->limit(10)->get(), + ]); + } + + return view('notifications.index', [ + 'notifications' => $request->user()->notifications()->latest()->get(), + ]); + } + + public function store(Request $request): RedirectResponse + { + abort_unless($request->user()->isAn('admin'), 403); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'body' => ['required', 'string', 'max:2000'], + 'target' => ['required', 'in:all,role,users'], + 'role' => ['required_if:target,role', 'in:admin,user'], + 'user_ids' => ['array'], + 'user_ids.*' => ['integer', 'exists:users,id'], + ]); + + $notification = new InAppNotification( + title: $validated['title'], + body: $validated['body'], + url: route('notifications.index'), + ); + + $recipients = match ($validated['target']) { + 'all' => User::query()->get(), + 'role' => User::query()->whereIs($validated['role'])->get(), + 'users' => User::query()->whereIn('id', $validated['user_ids'] ?? [])->get(), + }; + + Notification::send($recipients, $notification); + + return back()->with('status', 'Notification sent.'); + } + + public function markRead(Request $request, DatabaseNotification $notification): RedirectResponse + { + if ($notification->notifiable_id !== $request->user()?->id) { + abort(403); + } + + $notification->markAsRead(); + + return back(); + } + + public function markUnread(Request $request, DatabaseNotification $notification): RedirectResponse + { + if ($notification->notifiable_id !== $request->user()?->id) { + abort(403); + } + + $notification->markAsUnread(); + + return back(); + } +} diff --git a/app/Notifications/InAppNotification.php b/app/Notifications/InAppNotification.php new file mode 100644 index 0000000..9d12fe6 --- /dev/null +++ b/app/Notifications/InAppNotification.php @@ -0,0 +1,31 @@ + $this->title, + 'body' => $this->body, + 'url' => $this->url, + ]; + } +} diff --git a/database/migrations/2026_04_10_111853_create_notifications_table.php b/database/migrations/2026_04_10_111853_create_notifications_table.php new file mode 100644 index 0000000..d738032 --- /dev/null +++ b/database/migrations/2026_04_10_111853_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index df68027..526bfae 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -44,6 +44,11 @@ 'href' => route('admin.users.index'), 'icon' => 'heroicon-o-users', ], + [ + 'label' => 'Notifications', + 'href' => route('admin.notifications.index'), + 'icon' => 'heroicon-o-bell', + ], [ 'label' => 'Roles', 'href' => route('admin.roles.index'), diff --git a/resources/views/admin/notifications/index.blade.php b/resources/views/admin/notifications/index.blade.php new file mode 100644 index 0000000..a40e8bf --- /dev/null +++ b/resources/views/admin/notifications/index.blade.php @@ -0,0 +1,106 @@ +@php + $toolbarItems = [ + [ + 'label' => 'Ideas', + 'href' => route('home'), + 'icon' => 'heroicon-o-light-bulb', + ], + [ + 'label' => 'Planning', + 'href' => route('planning.index'), + 'icon' => 'heroicon-o-clipboard-document-list', + ], + [ + 'label' => 'Development', + 'href' => route('development.index'), + 'icon' => 'heroicon-o-code-bracket-square', + ], + [ + 'label' => 'Testing', + 'href' => route('testing.index'), + 'icon' => 'heroicon-o-beaker', + ], + [ + 'label' => 'Security', + 'href' => route('security.index'), + 'icon' => 'heroicon-o-shield-check', + ], + [ + 'label' => 'Ops', + 'href' => route('ops.index'), + 'icon' => 'heroicon-o-command-line', + ], + ]; + + $contextItems = [ + ['label' => 'Dashboard', 'href' => route('admin.dashboard'), 'icon' => 'heroicon-o-squares-2x2'], + ['label' => 'Users', 'href' => route('admin.users.index'), 'icon' => 'heroicon-o-users'], + ['label' => 'Notifications', 'href' => route('admin.notifications.index'), 'current' => true, 'icon' => 'heroicon-o-bell'], + ['label' => 'Roles', 'href' => route('admin.roles.index'), 'icon' => 'heroicon-o-shield-check'], + ]; +@endphp + + +
+ + +
+ +
+ @csrf + + + + + + + + + + + + + + + + + @foreach ($users as $user) + + @endforeach + + + Send notification + +
+ + +
+ @forelse ($notifications as $notification) +
+
+
+

{{ $notification->data['title'] ?? 'Notification' }}

+

{{ $notification->data['body'] ?? '' }}

+
+ + {{ $notification->read_at ? 'Read' : 'Unread' }} + +
+
+ @empty +

No notifications yet.

+ @endforelse +
+
+
+
+
diff --git a/resources/views/notifications/index.blade.php b/resources/views/notifications/index.blade.php new file mode 100644 index 0000000..ef728ac --- /dev/null +++ b/resources/views/notifications/index.blade.php @@ -0,0 +1,86 @@ +@php + $toolbarItems = [ + [ + 'label' => 'Ideas', + 'href' => route('ideas.index'), + 'icon' => 'heroicon-o-light-bulb', + ], + [ + 'label' => 'Planning', + 'href' => route('planning.index'), + 'icon' => 'heroicon-o-clipboard-document-list', + ], + [ + 'label' => 'Development', + 'href' => route('development.index'), + 'icon' => 'heroicon-o-code-bracket-square', + ], + [ + 'label' => 'Testing', + 'href' => route('testing.index'), + 'icon' => 'heroicon-o-beaker', + ], + [ + 'label' => 'Security', + 'href' => route('security.index'), + 'icon' => 'heroicon-o-shield-check', + ], + [ + 'label' => 'Ops', + 'href' => route('ops.index'), + 'icon' => 'heroicon-o-command-line', + ], + ]; + + $contextItems = [ + ['label' => 'Inbox', 'href' => route('notifications.index'), 'current' => true, 'icon' => 'heroicon-o-bell'], + ]; +@endphp + + +
+ + + +
+ @forelse ($notifications as $notification) +
+
+
+

{{ $notification->data['title'] ?? 'Notification' }}

+

{{ $notification->data['body'] ?? '' }}

+
+ + {{ $notification->read_at ? 'Read' : 'Unread' }} + +
+ +
+ @if ($notification->read_at) +
+ @csrf + Mark unread +
+ @else +
+ @csrf + Mark read +
+ @endif +
+
+ @empty +

You have no notifications yet.

+ @endforelse +
+
+
+
diff --git a/routes/web.php b/routes/web.php index fa0bd40..0a5f6c3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ except(['show']); Route::post('/ideas/{idea}/refine', [IdeaController::class, 'refine'])->name('ideas.refine'); Route::post('/ideas/{idea}/propose', [IdeaController::class, 'propose'])->name('ideas.propose'); + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::post('/notifications/{notification}/read', [NotificationController::class, 'markRead'])->name('notifications.read'); + Route::post('/notifications/{notification}/unread', [NotificationController::class, 'markUnread'])->name('notifications.unread'); }); Route::view('/planning', 'section-placeholder', ['title' => 'Planning'])->name('planning.index'); @@ -31,5 +35,9 @@ Route::put('/users/{user}', [UserController::class, 'update'])->name('users.update'); Route::post('/users/{user}/impersonate', [ImpersonationController::class, 'store'])->name('users.impersonate'); Route::delete('/impersonation', [ImpersonationController::class, 'destroy'])->name('impersonation.destroy'); + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::post('/notifications', [NotificationController::class, 'store'])->name('notifications.store'); + Route::post('/notifications/{notification}/read', [NotificationController::class, 'markRead'])->name('notifications.read'); + Route::post('/notifications/{notification}/unread', [NotificationController::class, 'markUnread'])->name('notifications.unread'); Route::view('/roles', 'section-placeholder', ['title' => 'Roles'])->name('roles.index'); }); diff --git a/tests/Feature/Admin/NotificationsTest.php b/tests/Feature/Admin/NotificationsTest.php new file mode 100644 index 0000000..60c60cf --- /dev/null +++ b/tests/Feature/Admin/NotificationsTest.php @@ -0,0 +1,77 @@ +create(); + $admin->assign('admin'); + $users = User::factory()->count(2)->create(); + + $this->actingAs($admin) + ->post(route('admin.notifications.store'), [ + 'title' => 'System update', + 'body' => 'We have a new internal update.', + 'target' => 'all', + ]) + ->assertRedirect(); + + Notification::assertSentTo($users[0], InAppNotification::class); + Notification::assertSentTo($users[1], InAppNotification::class); + Notification::assertSentTo($admin, InAppNotification::class); +}); + +it('allows admins to target notifications by role or user list', function (): void { + Notification::fake(); + + $admin = User::factory()->create(); + $admin->assign('admin'); + $targetAdmin = User::factory()->create(); + $targetAdmin->assign('admin'); + $targetUser = User::factory()->create(); + + $this->actingAs($admin) + ->post(route('admin.notifications.store'), [ + 'title' => 'Role update', + 'body' => 'This is for admins only.', + 'target' => 'role', + 'role' => 'admin', + ]) + ->assertRedirect(); + + Notification::assertSentTo($admin, InAppNotification::class); + Notification::assertSentTo($targetAdmin, InAppNotification::class); + Notification::assertNotSentTo($targetUser, InAppNotification::class); + + Notification::fake(); + + $this->actingAs($admin) + ->post(route('admin.notifications.store'), [ + 'title' => 'Chosen people', + 'body' => 'Only specific users should get this.', + 'target' => 'users', + 'user_ids' => [$targetUser->id], + ]) + ->assertRedirect(); + + Notification::assertSentTo($targetUser, InAppNotification::class); + Notification::assertNotSentTo($admin, InAppNotification::class); +}); + +it('forbids non-admins from sending notifications', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.notifications.store'), [ + 'title' => 'Nope', + 'body' => 'Should not send.', + 'target' => 'all', + ]) + ->assertForbidden(); +}); diff --git a/tests/Feature/Notifications/InboxTest.php b/tests/Feature/Notifications/InboxTest.php new file mode 100644 index 0000000..6d46f8a --- /dev/null +++ b/tests/Feature/Notifications/InboxTest.php @@ -0,0 +1,54 @@ +create(); + + $user->notify(new InAppNotification( + title: 'Welcome', + body: 'Your inbox is ready.', + url: route('home') + )); + + $notification = $user->notifications()->first(); + + $this->actingAs($user) + ->get(route('notifications.index')) + ->assertSuccessful() + ->assertSee('Welcome', false) + ->assertSee('Mark read', false); + + $this->actingAs($user) + ->post(route('notifications.read', $notification)) + ->assertRedirect(); + + $notification->refresh(); + + expect($notification->read_at)->not->toBeNull(); + + $this->actingAs($user) + ->get(route('notifications.index')) + ->assertSuccessful() + ->assertSee('Mark unread', false); +}); + +it('blocks users from changing other users notifications', function (): void { + $owner = User::factory()->create(); + $other = User::factory()->create(); + + $owner->notify(new InAppNotification( + title: 'Private', + body: 'Only one account should see this.' + )); + + $notification = $owner->notifications()->first(); + + $this->actingAs($other) + ->post(route('notifications.read', $notification)) + ->assertForbidden(); +});