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 new file mode 100644 index 0000000..c4e6fc3 --- /dev/null +++ b/app/Http/Controllers/IdeaController.php @@ -0,0 +1,119 @@ +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.'); + } + + public function refine(Idea $idea, IdeaRefinementService $service): RedirectResponse + { + Gate::authorize('update', $idea); + + return redirect() + ->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/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/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/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/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..bc2289f --- /dev/null +++ b/resources/views/ideas/edit.blade.php @@ -0,0 +1,148 @@ +@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, + ]) +
+

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

+
+
+ @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')) + +
+
+

Problem

+

{{ $ideaRefinement['problem'] }}

+
+
+

Users

+

{{ $ideaRefinement['users'] }}

+
+
+

Outcomes

+

{{ $ideaRefinement['outcomes'] }}

+
+
+

Scope gaps

+

{{ $ideaRefinement['scope_gaps'] }}

+
+
+
+

Follow-up questions

+

{{ $ideaRefinement['follow_up_questions'] }}

+
+
+ @endif + + +
+ @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..fa0bd40 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,9 @@ })->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::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(); +}); 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); +}); 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); +});