From 59cfc18f4862cdf5f5251a8953c3759d924a9940 Mon Sep 17 00:00:00 2001 From: Stefan Zwischenbrugger Date: Sun, 29 Mar 2026 20:50:30 +0200 Subject: [PATCH] Teilen mit anderen Benutzern --- .../Concerns/ResolvesCurrentShoppingList.php | 37 ++++++++ app/Http/Controllers/Controller.php | 4 +- .../Controllers/ShoppingListController.php | 56 ++++++++---- .../ShoppingListMemberController.php | 47 ++++++++++ .../ShoppingListSwitchController.php | 19 ++++ .../StoreShoppingListMemberRequest.php | 32 +++++++ app/Models/ItemPriceLog.php | 9 ++ app/Models/ShoppingItem.php | 14 ++- app/Models/ShoppingList.php | 41 +++++++++ app/Models/User.php | 13 ++- app/Policies/ShoppingListPolicy.php | 19 ++++ app/Providers/AppServiceProvider.php | 16 ++++ composer.json | 1 + ...reate_shopping_lists_and_members_table.php | 32 +++++++ ...grate_shopping_items_to_shopping_lists.php | 80 ++++++++++++++++ resources/views/shopping-list/index.blade.php | 91 +++++++++++++++++++ .../partials/item-pencil-panel.blade.php | 13 +++ routes/web.php | 8 +- 18 files changed, 508 insertions(+), 24 deletions(-) create mode 100644 app/Http/Controllers/Concerns/ResolvesCurrentShoppingList.php create mode 100644 app/Http/Controllers/ShoppingListMemberController.php create mode 100644 app/Http/Controllers/ShoppingListSwitchController.php create mode 100644 app/Http/Requests/StoreShoppingListMemberRequest.php create mode 100644 app/Models/ShoppingList.php create mode 100644 app/Policies/ShoppingListPolicy.php create mode 100644 database/migrations/2026_03_29_100000_create_shopping_lists_and_members_table.php create mode 100644 database/migrations/2026_03_29_100100_migrate_shopping_items_to_shopping_lists.php diff --git a/app/Http/Controllers/Concerns/ResolvesCurrentShoppingList.php b/app/Http/Controllers/Concerns/ResolvesCurrentShoppingList.php new file mode 100644 index 0000000..7b6d9cc --- /dev/null +++ b/app/Http/Controllers/Concerns/ResolvesCurrentShoppingList.php @@ -0,0 +1,37 @@ +user(); + abort_if($user === null, 403); + + $lists = $user->accessibleShoppingLists()->with('owner')->orderBy('name')->get(); + + if ($lists->isEmpty()) { + $list = ShoppingList::query()->create([ + 'owner_id' => $user->id, + 'name' => 'Einkaufsliste', + ]); + $list->members()->attach($user->id); + $list->load('owner'); + $lists = collect([$list]); + } + + $sessionId = $request->session()->get('current_shopping_list_id'); + $current = $lists->firstWhere('id', $sessionId); + + if ($current === null) { + $current = $lists->first(); + $request->session()->put('current_shopping_list_id', $current->id); + } + + return $current; + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5..e7f7c94 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; + abstract class Controller { - // + use AuthorizesRequests; } diff --git a/app/Http/Controllers/ShoppingListController.php b/app/Http/Controllers/ShoppingListController.php index 9257ff0..b418dd1 100644 --- a/app/Http/Controllers/ShoppingListController.php +++ b/app/Http/Controllers/ShoppingListController.php @@ -2,30 +2,38 @@ namespace App\Http\Controllers; +use App\Http\Controllers\Concerns\ResolvesCurrentShoppingList; use App\Http\Requests\StoreShoppingItemRequest; use App\Http\Requests\ToggleShoppingItemRequest; use App\Http\Requests\UpdateShoppingItemRequest; use App\Models\ItemPriceLog; use App\Models\ShoppingItem; +use App\Models\ShoppingList; use App\Models\Store; +use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; -use Illuminate\Contracts\View\View; class ShoppingListController extends Controller { + use ResolvesCurrentShoppingList; + public function index(): View { - /** @var \App\Models\User|null $user */ - $user = auth()->user(); - if ($user === null) { - abort(403); - } + $request = request(); + /** @var \App\Models\User $user */ + $user = $request->user(); + abort_if($user === null, 403); + + $currentList = $this->currentShoppingList($request); + $this->authorize('view', $currentList); + + $accessibleLists = $user->accessibleShoppingLists()->orderBy('name')->get(); $items = ShoppingItem::query() - ->where('user_id', $user->id) - ->with(['store', 'latestPriceLog.store']) + ->where('shopping_list_id', $currentList->id) + ->with(['store', 'latestPriceLog.store', 'creator']) ->latest() ->get(); @@ -41,7 +49,12 @@ class ShoppingListController extends Controller ->map(fn ($group) => $group->sum(fn (ShoppingItem $item) => (float) $item->latestPriceLog->price_decimal)) ->sortDesc(); + $members = $currentList->members()->orderBy('name')->get(); + return view('shopping-list.index', [ + 'currentList' => $currentList, + 'accessibleLists' => $accessibleLists, + 'members' => $members, 'openItems' => $openItems, 'doneItems' => $doneItems, 'stores' => $stores, @@ -52,6 +65,9 @@ class ShoppingListController extends Controller public function store(StoreShoppingItemRequest $request): RedirectResponse { + $currentList = $this->currentShoppingList($request); + $this->authorize('view', $currentList); + $storeId = $this->resolveStoreId( $request->integer('store_id') ?: null, $request->input('new_store_name'), @@ -59,7 +75,8 @@ class ShoppingListController extends Controller ); ShoppingItem::query()->create([ - 'user_id' => $request->user()->id, + 'shopping_list_id' => $currentList->id, + 'created_by' => $request->user()->id, 'product_name' => $request->string('product_name')->toString(), 'quantity' => $request->filled('quantity') ? $request->string('quantity')->toString() : null, 'store_id' => $storeId, @@ -71,9 +88,9 @@ class ShoppingListController extends Controller public function update(UpdateShoppingItemRequest $request, ShoppingItem $shoppingItem): RedirectResponse { - if ($shoppingItem->user_id !== $request->user()->id) { - abort(403); - } + $currentList = $this->currentShoppingList($request); + $this->authorize('view', $currentList); + $this->assertItemInList($shoppingItem, $currentList); $storeId = $this->resolveStoreId( $request->integer('store_id') ?: null, @@ -92,9 +109,9 @@ class ShoppingListController extends Controller public function toggle(ToggleShoppingItemRequest $request, ShoppingItem $shoppingItem): RedirectResponse { - if ($shoppingItem->user_id !== $request->user()->id) { - abort(403); - } + $currentList = $this->currentShoppingList($request); + $this->authorize('view', $currentList); + $this->assertItemInList($shoppingItem, $currentList); $isDone = $request->boolean('is_done'); $storeId = $this->resolveStoreId( @@ -114,13 +131,13 @@ class ShoppingListController extends Controller } $shoppingItem->update($payload); - if (!$isDone) { + if (! $isDone) { return; } $photoPath = $request->file('photo')?->store('price-photos', 'public'); - if (!$request->filled('price_decimal') && $photoPath === null) { + if (! $request->filled('price_decimal') && $photoPath === null) { return; } @@ -138,6 +155,11 @@ class ShoppingListController extends Controller return back()->with('status', 'Eintrag wurde aktualisiert.'); } + private function assertItemInList(ShoppingItem $shoppingItem, ShoppingList $shoppingList): void + { + abort_if((int) $shoppingItem->shopping_list_id !== (int) $shoppingList->id, 403); + } + private function resolveStoreId(?int $storeId, ?string $newStoreName, int $userId): ?int { $newStoreName = trim((string) $newStoreName); diff --git a/app/Http/Controllers/ShoppingListMemberController.php b/app/Http/Controllers/ShoppingListMemberController.php new file mode 100644 index 0000000..9252aad --- /dev/null +++ b/app/Http/Controllers/ShoppingListMemberController.php @@ -0,0 +1,47 @@ +authorize('manageMembers', $shoppingList); + + $member = User::query()->where('email', $request->validated('email'))->firstOrFail(); + + if ($member->id === $request->user()->id) { + return back()->withErrors(['email' => 'Du bist bereits Mitglied dieser Liste.']); + } + + if ($shoppingList->members()->whereKey($member->id)->exists()) { + return back()->withErrors(['email' => 'Dieser Benutzer ist bereits eingetragen.']); + } + + $shoppingList->members()->attach($member->id); + + return back()->with('status', $member->name.' kann die Liste jetzt mitbearbeiten.'); + } + + public function destroy(ShoppingList $shoppingList, User $user): RedirectResponse + { + $this->authorize('manageMembers', $shoppingList); + + if ($user->id === $shoppingList->owner_id) { + return back()->withErrors(['member' => 'Der Listenbesitzer kann nicht entfernt werden.']); + } + + if (! $shoppingList->members()->whereKey($user->id)->exists()) { + abort(404); + } + + $shoppingList->members()->detach($user->id); + + return back()->with('status', 'Zugriff wurde entfernt.'); + } +} diff --git a/app/Http/Controllers/ShoppingListSwitchController.php b/app/Http/Controllers/ShoppingListSwitchController.php new file mode 100644 index 0000000..534f96e --- /dev/null +++ b/app/Http/Controllers/ShoppingListSwitchController.php @@ -0,0 +1,19 @@ +authorize('view', $shoppingList); + + $request->session()->put('current_shopping_list_id', $shoppingList->id); + + return redirect()->route('dashboard'); + } +} diff --git a/app/Http/Requests/StoreShoppingListMemberRequest.php b/app/Http/Requests/StoreShoppingListMemberRequest.php new file mode 100644 index 0000000..9420171 --- /dev/null +++ b/app/Http/Requests/StoreShoppingListMemberRequest.php @@ -0,0 +1,32 @@ +user() !== null; + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255', 'exists:users,email'], + ]; + } + + public function messages(): array + { + return [ + 'email.required' => 'Bitte eine E-Mail-Adresse angeben.', + 'email.email' => 'Die E-Mail-Adresse ist ungueltig.', + 'email.exists' => 'Es gibt keinen Benutzer mit dieser E-Mail. Die Person muss sich zuerst registrieren.', + ]; + } +} diff --git a/app/Models/ItemPriceLog.php b/app/Models/ItemPriceLog.php index 2fb99f2..49315db 100644 --- a/app/Models/ItemPriceLog.php +++ b/app/Models/ItemPriceLog.php @@ -34,4 +34,13 @@ class ItemPriceLog extends Model { return $this->belongsTo(Store::class); } + + public function photoUrl(): ?string + { + if ($this->photo_path === null || $this->photo_path === '') { + return null; + } + + return asset('storage/'.ltrim($this->photo_path, '/')); + } } diff --git a/app/Models/ShoppingItem.php b/app/Models/ShoppingItem.php index 7a97284..a91ce91 100644 --- a/app/Models/ShoppingItem.php +++ b/app/Models/ShoppingItem.php @@ -4,13 +4,14 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; class ShoppingItem extends Model { protected $fillable = [ - 'user_id', + 'shopping_list_id', + 'created_by', 'product_name', 'quantity', 'store_id', @@ -26,9 +27,14 @@ class ShoppingItem extends Model ]; } - public function user(): BelongsTo + public function shoppingList(): BelongsTo { - return $this->belongsTo(User::class); + return $this->belongsTo(ShoppingList::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); } public function store(): BelongsTo diff --git a/app/Models/ShoppingList.php b/app/Models/ShoppingList.php new file mode 100644 index 0000000..965e05c --- /dev/null +++ b/app/Models/ShoppingList.php @@ -0,0 +1,41 @@ +belongsTo(User::class, 'owner_id'); + } + + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'shopping_list_user')->withTimestamps(); + } + + public function items(): HasMany + { + return $this->hasMany(ShoppingItem::class); + } + + public function isOwner(User $user): bool + { + return (int) $this->owner_id === (int) $user->id; + } + + public function isMember(User $user): bool + { + return $this->members()->whereKey($user->id)->exists(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 048e6ef..864dd27 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -50,6 +51,16 @@ class User extends Authenticatable implements MustVerifyEmail public function shoppingItems(): HasMany { - return $this->hasMany(ShoppingItem::class); + return $this->hasMany(ShoppingItem::class, 'created_by'); + } + + public function ownedShoppingLists(): HasMany + { + return $this->hasMany(ShoppingList::class, 'owner_id'); + } + + public function accessibleShoppingLists(): BelongsToMany + { + return $this->belongsToMany(ShoppingList::class, 'shopping_list_user')->withTimestamps(); } } diff --git a/app/Policies/ShoppingListPolicy.php b/app/Policies/ShoppingListPolicy.php new file mode 100644 index 0000000..bbf398f --- /dev/null +++ b/app/Policies/ShoppingListPolicy.php @@ -0,0 +1,19 @@ +isMember($user); + } + + public function manageMembers(User $user, ShoppingList $shoppingList): bool + { + return $shoppingList->isOwner($user); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 95abf06..61eb0f5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,8 +2,12 @@ namespace App\Providers; +use App\Models\ShoppingList; +use App\Models\User; use App\Services\PriceScan\NullPriceScanService; use App\Services\PriceScan\PriceScanServiceInterface; +use Illuminate\Auth\Events\Registered; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; @@ -24,5 +28,17 @@ class AppServiceProvider extends ServiceProvider { // Compatibility for older MariaDB/MySQL index length limits. Schema::defaultStringLength(191); + + Event::listen(Registered::class, function (Registered $event): void { + $user = $event->user; + if (! $user instanceof User) { + return; + } + $list = ShoppingList::query()->create([ + 'owner_id' => $user->id, + 'name' => 'Einkaufsliste', + ]); + $list->members()->attach($user->id); + }); } } diff --git a/composer.json b/composer.json index fd3da06..8dfec8b 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.2", + "doctrine/dbal": "^4.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1" }, diff --git a/database/migrations/2026_03_29_100000_create_shopping_lists_and_members_table.php b/database/migrations/2026_03_29_100000_create_shopping_lists_and_members_table.php new file mode 100644 index 0000000..dab00ff --- /dev/null +++ b/database/migrations/2026_03_29_100000_create_shopping_lists_and_members_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('owner_id')->constrained('users')->cascadeOnDelete(); + $table->string('name')->default('Einkaufsliste'); + $table->timestamps(); + }); + + Schema::create('shopping_list_user', function (Blueprint $table) { + $table->foreignId('shopping_list_id')->constrained('shopping_lists')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + + $table->primary(['shopping_list_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('shopping_list_user'); + Schema::dropIfExists('shopping_lists'); + } +}; diff --git a/database/migrations/2026_03_29_100100_migrate_shopping_items_to_shopping_lists.php b/database/migrations/2026_03_29_100100_migrate_shopping_items_to_shopping_lists.php new file mode 100644 index 0000000..38e29a2 --- /dev/null +++ b/database/migrations/2026_03_29_100100_migrate_shopping_items_to_shopping_lists.php @@ -0,0 +1,80 @@ +unsignedBigInteger('shopping_list_id')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + }); + + $userIds = DB::table('users')->pluck('id'); + $map = []; + + foreach ($userIds as $userId) { + $map[$userId] = DB::table('shopping_lists')->insertGetId([ + 'owner_id' => $userId, + 'name' => 'Einkaufsliste', + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('shopping_list_user')->insert([ + 'shopping_list_id' => $map[$userId], + 'user_id' => $userId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $items = DB::table('shopping_items')->select('id', 'user_id')->get(); + foreach ($items as $item) { + if (! isset($map[$item->user_id])) { + continue; + } + DB::table('shopping_items')->where('id', $item->id)->update([ + 'shopping_list_id' => $map[$item->user_id], + 'created_by' => $item->user_id, + ]); + } + + Schema::table('shopping_items', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropColumn('user_id'); + }); + + Schema::table('shopping_items', function (Blueprint $table) { + $table->unsignedBigInteger('shopping_list_id')->nullable(false)->change(); + $table->unsignedBigInteger('created_by')->nullable(false)->change(); + }); + + Schema::table('shopping_items', function (Blueprint $table) { + $table->foreign('shopping_list_id')->references('id')->on('shopping_lists')->cascadeOnDelete(); + $table->foreign('created_by')->references('id')->on('users')->nullOnDelete(); + $table->index(['shopping_list_id', 'is_done']); + $table->index(['shopping_list_id', 'store_id']); + }); + } + + public function down(): void + { + Schema::table('shopping_items', function (Blueprint $table) { + $table->dropForeign(['shopping_list_id']); + $table->dropForeign(['created_by']); + $table->dropIndex(['shopping_list_id', 'is_done']); + $table->dropIndex(['shopping_list_id', 'store_id']); + }); + + Schema::table('shopping_items', function (Blueprint $table) { + $table->dropColumn(['shopping_list_id', 'created_by']); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->index(['user_id', 'is_done']); + $table->index(['user_id', 'store_id']); + }); + } +}; diff --git a/resources/views/shopping-list/index.blade.php b/resources/views/shopping-list/index.blade.php index d16813e..7e08fbd 100644 --- a/resources/views/shopping-list/index.blade.php +++ b/resources/views/shopping-list/index.blade.php @@ -28,6 +28,78 @@ @endif +
+

Aktuelle Liste

+

+ {{ $currentList->name }} + @if($currentList->owner_id === auth()->id()) + (du bist Besitzer) + @else + (geteilt von {{ $currentList->owner->name }}) + @endif +

+ @if($accessibleLists->count() > 1) +
+ @csrf + + +
+

Nach Auswahl wird die Seite neu geladen.

+ @endif +
+ + @can('manageMembers', $currentList) +
+

Liste teilen

+

Weitere Personen koennen dieselbe Liste bearbeiten. Sie muessen bereits ein Konto registriert haben.

+
+ @csrf + + +
+

Mitglieder

+
    + @foreach($members as $member) +
  • +
    + {{ $member->name }} + {{ $member->email }} + @if($member->id === $currentList->owner_id) + Besitzer + @endif +
    + @if($member->id !== $currentList->owner_id) +
    + @csrf + @method('DELETE') + +
    + @endif +
  • + @endforeach +
+
+ @endcan +

Neuer Eintrag

Nur Namen eingeben, mit Enter speichern.

@@ -80,6 +152,9 @@ + @if($item->creator && (int) $item->creator->id !== (int) auth()->id()) +
von {{ $item->creator->name }}
+ @endif @if($item->quantity || $item->store)
@if($item->quantity) @@ -127,12 +202,28 @@ >
{{ $item->product_name }}
+ @if($item->creator && (int) $item->creator->id !== (int) auth()->id()) +
von {{ $item->creator->name }}
+ @endif
Erledigt: {{ optional($item->done_at)->format('d.m.Y H:i') ?: '-' }}
Letzter Preis: {{ $item->latestPriceLog?->price_decimal !== null ? number_format((float) $item->latestPriceLog->price_decimal, 2, ',', '.') . ' EUR' : '-' }}
+ @if($item->latestPriceLog?->photo_path) +
+

Kassenbon / Foto

+ + Kassenbon + +
+ @endif
@include('shopping-list.partials.item-pencil-panel', [ diff --git a/resources/views/shopping-list/partials/item-pencil-panel.blade.php b/resources/views/shopping-list/partials/item-pencil-panel.blade.php index bf4cfeb..293f132 100644 --- a/resources/views/shopping-list/partials/item-pencil-panel.blade.php +++ b/resources/views/shopping-list/partials/item-pencil-panel.blade.php @@ -112,6 +112,19 @@ bei {{ $item->latestPriceLog->store?->name ?: 'ohne Geschaeft' }} ({{ optional($item->latestPriceLog->logged_at)->format('d.m.Y H:i') }})
+ @if($item->latestPriceLog->photo_path) +
+

Letztes Foto

+ + Kassenbon + +
+ @endif @endif