diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index 87f2a19..cb94715 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -112,7 +112,7 @@ class ProductController extends Controller 'category_id' => 'required|exists:categories,id', 'tags' => 'nullable|array', 'tags.*' => 'exists:tags,id', - ] + $this->detailRules()); + ] + $this->detailRules(Auth::id())); foreach (array_keys(Product::BADGES) as $badge) { $validated[$badge] = $request->boolean($badge); @@ -150,7 +150,7 @@ class ProductController extends Controller abort(404); } - $product->load(['user', 'category', 'tags', 'comments.user', 'upvotes', 'bookmarks']); + $product->load(['user', 'category', 'tags', 'members', 'comments.user', 'upvotes', 'bookmarks']); return view('products.show', compact('product')); } @@ -182,7 +182,7 @@ class ProductController extends Controller 'category_id' => 'required|exists:categories,id', 'tags' => 'nullable|array', 'tags.*' => 'exists:tags,id', - ] + $this->detailRules()); + ] + $this->detailRules($product->user_id)); // Badges are locked for non-moderators once the product is approved if ($product->status !== 'approved' || Auth::user()->hasAnyRole(['moderator', 'admin'])) { @@ -218,7 +218,7 @@ class ProductController extends Controller /** * Validation rules for the product detail fields. */ - protected function detailRules(): array + protected function detailRules(int $ownerId): array { return [ 'free_tier' => 'boolean', @@ -234,6 +234,9 @@ class ProductController extends Controller 'plans.*.price' => 'required|numeric|min:0|max:99999', 'plans.*.period' => ['required', Rule::in(PricingPlan::PERIODS)], 'plans.*.description' => 'nullable|string|max:255', + 'members' => 'nullable|array', + 'members.*.user_id' => ['required', 'distinct', 'exists:users,id', Rule::notIn([$ownerId])], + 'members.*.role' => 'required|string|max:100', ]; } @@ -254,6 +257,13 @@ class ProductController extends Controller ]); } + // The submitted member list replaces all existing members + $members = []; + foreach ((array) $request->input('members', []) as $member) { + $members[$member['user_id']] = ['role' => $member['role']]; + } + $product->members()->sync($members); + // Only rows belonging to this product may be removed $ids = array_filter((array) $request->input('remove_screenshots', [])); if ($ids) { diff --git a/app/Http/Controllers/UserSearchController.php b/app/Http/Controllers/UserSearchController.php new file mode 100644 index 0000000..b1ec877 --- /dev/null +++ b/app/Http/Controllers/UserSearchController.php @@ -0,0 +1,34 @@ +query('q', '')); + + if (mb_strlen($q) < 2) { + return response()->json([]); + } + + $escaped = addcslashes($q, '\\%_'); + + return response()->json( + User::query() + ->where('name', 'like', "%{$escaped}%") + ->orderBy('name') + ->limit(10) + ->get(['id', 'name', 'slug']) + ->map(fn (User $u) => ['id' => $u->id, 'name' => $u->name, 'slug' => $u->slug]) + ->values() + ); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index 30894fa..dd279e0 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -138,6 +138,17 @@ class Product extends Model return $this->hasMany(PricingPlan::class)->orderBy('position')->orderBy('id'); } + /** + * Get the team members of the product (besides the submitter). + */ + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'product_members') + ->withPivot('role') + ->withTimestamps() + ->orderBy('product_members.id'); + } + /** * Get the user that owns the product. */ diff --git a/database/migrations/2026_10_17_130000_create_product_members_table.php b/database/migrations/2026_10_17_130000_create_product_members_table.php new file mode 100644 index 0000000..f697a43 --- /dev/null +++ b/database/migrations/2026_10_17_130000_create_product_members_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('role', 100); + $table->timestamps(); + + $table->unique(['product_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_members'); + } +}; diff --git a/resources/views/components/product/team-form.blade.php b/resources/views/components/product/team-form.blade.php new file mode 100644 index 0000000..e8ca9ac --- /dev/null +++ b/resources/views/components/product/team-form.blade.php @@ -0,0 +1,60 @@ +@props(['members' => []]) + +@php + $initial = collect(old('members', collect($members)->map(fn ($m) => [ + 'user_id' => $m->id, + 'name' => $m->name, + 'role' => $m->pivot->role, + ])->all()))->map(fn ($m) => [ + 'user_id' => $m['user_id'] ?? '', + 'name' => $m['name'] ?? ('User #' . ($m['user_id'] ?? '')), + 'role' => $m['role'] ?? '', + ])->values()->all(); + $input = 'block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm'; +@endphp + + +
+ +
+ + +
+
+ +
+ @foreach($errors->get('members.*') as $messages) +

{{ $messages[0] }}

+ @endforeach + @error('members') +

{{ $message }}

+ @enderror +
diff --git a/resources/views/components/product/team.blade.php b/resources/views/components/product/team.blade.php new file mode 100644 index 0000000..0bfe581 --- /dev/null +++ b/resources/views/components/product/team.blade.php @@ -0,0 +1,17 @@ +@props(['product']) + +
class('bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6') }}> +

Team

+ +
diff --git a/resources/views/products/create.blade.php b/resources/views/products/create.blade.php index fca263d..a563381 100644 --- a/resources/views/products/create.blade.php +++ b/resources/views/products/create.blade.php @@ -209,6 +209,8 @@ + +
diff --git a/resources/views/products/edit.blade.php b/resources/views/products/edit.blade.php index 4f57482..9005083 100644 --- a/resources/views/products/edit.blade.php +++ b/resources/views/products/edit.blade.php @@ -253,6 +253,8 @@ + +
diff --git a/resources/views/products/show.blade.php b/resources/views/products/show.blade.php index eb54304..71283f8 100644 --- a/resources/views/products/show.blade.php +++ b/resources/views/products/show.blade.php @@ -282,7 +282,7 @@
- +
diff --git a/routes/web.php b/routes/web.php index 8961077..08233f4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\ProductController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\TagController; use App\Http\Controllers\UpvoteController; +use App\Http\Controllers\UserSearchController; use App\Http\Controllers\WaitlistController; use Illuminate\Support\Facades\Route; @@ -44,6 +45,9 @@ Route::middleware(['auth'])->group(function () { Route::put('/products/{product}', [ProductController::class, 'update'])->name('products.update'); Route::delete('/products/{product}', [ProductController::class, 'destroy'])->name('products.destroy'); + // User search (team member picker) + Route::get('/users/search', UserSearchController::class)->name('users.search'); + // Upvotes Route::post('/products/{product}/upvote', [UpvoteController::class, 'store'])->name('products.upvote'); Route::get('/upvotes', [UpvoteController::class, 'index'])->name('upvotes.index'); diff --git a/tests/Feature/ProductTeamTest.php b/tests/Feature/ProductTeamTest.php new file mode 100644 index 0000000..d60e61c --- /dev/null +++ b/tests/Feature/ProductTeamTest.php @@ -0,0 +1,150 @@ + 'Team Product', + 'description' => 'A product description', + 'website_url' => 'https://example.com', + 'category_id' => makeCategory()->id, + ], $overrides); +} + +test('user search returns matching users, requires auth', function () { + $this->getJson(route('users.search', ['q' => 'ali']))->assertUnauthorized(); + + $viewer = userWithRole('user'); + $alice = User::factory()->create(['name' => 'Alice Wonder']); + User::factory()->create(['name' => 'Bob Builder']); + + $response = $this->actingAs($viewer)->getJson(route('users.search', ['q' => 'lic']))->assertOk(); + + expect($response->json())->toHaveCount(1); + expect($response->json(0))->toBe(['id' => $alice->id, 'name' => 'Alice Wonder', 'slug' => $alice->slug]); +}); + +test('user search returns empty for one character queries', function () { + $viewer = userWithRole('user'); + User::factory()->create(['name' => 'Alice Wonder']); + + $this->actingAs($viewer)->getJson(route('users.search', ['q' => 'a']))->assertOk()->assertExactJson([]); +}); + +test('submitter adds members with roles', function () { + $user = userWithRole('user'); + $a = User::factory()->create(); + $b = User::factory()->create(); + + $this->actingAs($user)->post(route('products.store'), teamPayload([ + 'members' => [ + ['user_id' => $a->id, 'role' => 'Designer'], + ['user_id' => $b->id, 'role' => 'Engineer'], + ], + ]))->assertRedirect()->assertSessionHasNoErrors(); + + $members = Product::firstOrFail()->members; + expect($members->pluck('id')->all())->toEqualCanonicalizing([$a->id, $b->id]); + expect($members->firstWhere('id', $a->id)->pivot->role)->toBe('Designer'); +}); + +test('update replaces members and uses the product owner for the owner rule', function () { + $owner = userWithRole('user'); + $admin = userWithRole('admin'); + $a = User::factory()->create(); + $b = User::factory()->create(); + $product = Product::factory()->pending()->create(['user_id' => $owner->id]); + $product->members()->attach($a->id, ['role' => 'Old']); + + $this->actingAs($admin)->put(route('products.update', $product), teamPayload([ + 'category_id' => $product->category_id, + 'members' => [['user_id' => $owner->id, 'role' => 'Founder']], + ]))->assertSessionHasErrors('members.0.user_id'); + expect($product->members()->pluck('users.id')->all())->toBe([$a->id]); + + $this->actingAs($admin)->put(route('products.update', $product), teamPayload([ + 'category_id' => $product->category_id, + 'members' => [['user_id' => $admin->id, 'role' => 'Helper'], ['user_id' => $b->id, 'role' => 'New']], + ]))->assertRedirect()->assertSessionHasNoErrors(); + expect($product->members()->pluck('users.id')->all())->toEqualCanonicalizing([$admin->id, $b->id]); +}); + +test('submitter cannot be added as a member', function () { + $user = userWithRole('user'); + + $this->actingAs($user)->post(route('products.store'), teamPayload([ + 'members' => [['user_id' => $user->id, 'role' => 'Founder']], + ]))->assertSessionHasErrors('members.0.user_id'); + + expect(Product::count())->toBe(0); +}); + +test('same user cannot be added twice', function () { + $user = userWithRole('user'); + $a = User::factory()->create(); + + $this->actingAs($user)->post(route('products.store'), teamPayload([ + 'members' => [ + ['user_id' => $a->id, 'role' => 'One'], + ['user_id' => $a->id, 'role' => 'Two'], + ], + ]))->assertSessionHasErrors(); + + expect(Product::count())->toBe(0); + expect(\DB::table('product_members')->count())->toBe(0); +}); + +test('unknown user id fails validation', function () { + $user = userWithRole('user'); + + $this->actingAs($user)->post(route('products.store'), teamPayload([ + 'members' => [['user_id' => 999999, 'role' => 'Ghost']], + ]))->assertSessionHasErrors('members.0.user_id'); + + expect(Product::count())->toBe(0); +}); + +test('missing role fails validation', function () { + $user = userWithRole('user'); + $a = User::factory()->create(); + + $this->actingAs($user)->post(route('products.store'), teamPayload([ + 'members' => [['user_id' => $a->id, 'role' => '']], + ]))->assertSessionHasErrors('members.0.role'); + + expect(Product::count())->toBe(0); +}); + +test('deleting a user removes their membership', function () { + $product = Product::factory()->approved()->create(); + $member = User::factory()->create(); + $product->members()->attach($member->id, ['role' => 'Dev']); + + $member->delete(); + + expect(\DB::table('product_members')->count())->toBe(0); + expect(Product::find($product->id))->not->toBeNull(); +}); + +test('show page lists submitter first as Author then members linking to profiles', function () { + $product = Product::factory()->approved()->create(); + $member = User::factory()->create(['name' => 'Zed Member']); + $product->members()->attach($member->id, ['role' => 'Designer']); + + $this->get(route('products.show', $product))->assertOk() + ->assertSeeInOrder([$product->user->name, 'Author', 'Zed Member', 'Designer']) + ->assertSee(route('profile.show', $member), false); +}); + +test('no members shows only the submitter as author', function () { + $product = Product::factory()->approved()->create(); + + $this->get(route('products.show', $product))->assertOk() + ->assertSee('Author') + ->assertDontSee('data-team-members', false); +});