Teilen mit anderen Benutzern

This commit is contained in:
Stefan Zwischenbrugger 2026-03-29 20:50:30 +02:00
parent 7306444e35
commit 59cfc18f48
18 changed files with 508 additions and 24 deletions

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Concerns;
use App\Models\ShoppingList;
use Illuminate\Http\Request;
trait ResolvesCurrentShoppingList
{
protected function currentShoppingList(Request $request): ShoppingList
{
$user = $request->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;
}
}

View File

@ -2,7 +2,9 @@
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
//
use AuthorizesRequests;
}

View File

@ -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);

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreShoppingListMemberRequest;
use App\Models\ShoppingList;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
class ShoppingListMemberController extends Controller
{
public function store(StoreShoppingListMemberRequest $request, ShoppingList $shoppingList): RedirectResponse
{
$this->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.');
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers;
use App\Models\ShoppingList;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ShoppingListSwitchController extends Controller
{
public function __invoke(Request $request, ShoppingList $shoppingList): RedirectResponse
{
$this->authorize('view', $shoppingList);
$request->session()->put('current_shopping_list_id', $shoppingList->id);
return redirect()->route('dashboard');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreShoppingListMemberRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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.',
];
}
}

View File

@ -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, '/'));
}
}

View File

@ -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

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ShoppingList extends Model
{
protected $fillable = [
'owner_id',
'name',
];
public function owner(): BelongsTo
{
return $this->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();
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Policies;
use App\Models\ShoppingList;
use App\Models\User;
class ShoppingListPolicy
{
public function view(User $user, ShoppingList $shoppingList): bool
{
return $shoppingList->isMember($user);
}
public function manageMembers(User $user, ShoppingList $shoppingList): bool
{
return $shoppingList->isOwner($user);
}
}

View File

@ -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);
});
}
}

View File

@ -7,6 +7,7 @@
"license": "MIT",
"require": {
"php": "^8.2",
"doctrine/dbal": "^4.0",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1"
},

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shopping_lists', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,80 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('shopping_items', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -28,6 +28,78 @@
</div>
@endif
<div class="bg-white overflow-hidden shadow-sm sm:rounded-xl p-4 sm:p-6">
<h3 class="text-lg font-semibold mb-2">Aktuelle Liste</h3>
<p class="text-sm text-gray-600 mb-3">
<span class="font-medium text-gray-800">{{ $currentList->name }}</span>
@if($currentList->owner_id === auth()->id())
<span class="text-gray-500">(du bist Besitzer)</span>
@else
<span class="text-gray-500">(geteilt von {{ $currentList->owner->name }})</span>
@endif
</p>
@if($accessibleLists->count() > 1)
<form method="POST" action="{{ route('shopping-lists.switch', $currentList) }}" id="shopping-list-switch-form" class="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center sm:flex-wrap">
@csrf
<label for="list-switch" class="text-sm text-gray-600 shrink-0">Wechseln zu:</label>
<select
id="list-switch"
class="rounded-md border-gray-300 text-sm min-h-[44px] max-w-md flex-1"
onchange="this.form.action = '{{ url('/shopping-lists') }}/' + this.value + '/switch'; this.form.requestSubmit();"
>
@foreach($accessibleLists as $list)
<option value="{{ $list->id }}" @selected($list->id === $currentList->id)>
{{ $list->name }} @if($list->owner_id === auth()->id()) (Besitzer) @else ({{ $list->owner->name }}) @endif
</option>
@endforeach
</select>
</form>
<p class="text-xs text-gray-500 mt-2">Nach Auswahl wird die Seite neu geladen.</p>
@endif
</div>
@can('manageMembers', $currentList)
<div class="bg-white overflow-hidden shadow-sm sm:rounded-xl p-4 sm:p-6">
<h3 class="text-lg font-semibold mb-2">Liste teilen</h3>
<p class="text-sm text-gray-600 mb-3">Weitere Personen koennen dieselbe Liste bearbeiten. Sie muessen bereits ein Konto registriert haben.</p>
<form method="POST" action="{{ route('shopping-lists.members.store', $currentList) }}" class="flex flex-col sm:flex-row gap-2 mb-6">
@csrf
<input
type="email"
name="email"
value="{{ old('email') }}"
placeholder="E-Mail-Adresse des Benutzers"
class="flex-1 rounded-md border-gray-300 shadow-sm min-h-[44px] text-base"
autocomplete="email"
>
<button type="submit" class="shrink-0 rounded-md bg-indigo-600 text-white px-5 min-h-[44px] text-base font-medium hover:bg-indigo-700">
Einladen
</button>
</form>
<h4 class="text-sm font-semibold text-gray-800 mb-2">Mitglieder</h4>
<ul class="divide-y divide-gray-100 border border-gray-200 rounded-lg">
@foreach($members as $member)
<li class="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm">
<div>
<span class="font-medium text-gray-900">{{ $member->name }}</span>
<span class="text-gray-500">{{ $member->email }}</span>
@if($member->id === $currentList->owner_id)
<span class="ml-2 text-xs text-gray-500">Besitzer</span>
@endif
</div>
@if($member->id !== $currentList->owner_id)
<form method="POST" action="{{ route('shopping-lists.members.destroy', [$currentList, $member]) }}" onsubmit="return confirm('Zugriff entfernen?');">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800 text-sm font-medium">Entfernen</button>
</form>
@endif
</li>
@endforeach
</ul>
</div>
@endcan
<div class="bg-white overflow-hidden shadow-sm sm:rounded-xl p-4 sm:p-6">
<h3 class="text-lg font-semibold mb-3">Neuer Eintrag</h3>
<p class="text-sm text-gray-600 mb-3">Nur Namen eingeben, mit <kbd class="px-1 bg-gray-100 rounded text-xs">Enter</kbd> speichern.</p>
@ -80,6 +152,9 @@
<label for="open-item-{{ $item->id }}" class="cursor-pointer text-base font-medium leading-snug text-gray-900">
{{ $item->product_name }}
</label>
@if($item->creator && (int) $item->creator->id !== (int) auth()->id())
<div class="text-xs text-gray-500 mt-0.5">von {{ $item->creator->name }}</div>
@endif
@if($item->quantity || $item->store)
<div class="mt-0.5 text-sm text-gray-500">
@if($item->quantity)
@ -127,12 +202,28 @@
>
<div class="min-w-0 flex-1">
<div class="font-medium text-gray-700 line-through">{{ $item->product_name }}</div>
@if($item->creator && (int) $item->creator->id !== (int) auth()->id())
<div class="text-xs text-gray-500">von {{ $item->creator->name }}</div>
@endif
<div class="text-sm text-gray-600">
Erledigt: {{ optional($item->done_at)->format('d.m.Y H:i') ?: '-' }}
</div>
<div class="text-sm text-gray-600">
Letzter Preis: {{ $item->latestPriceLog?->price_decimal !== null ? number_format((float) $item->latestPriceLog->price_decimal, 2, ',', '.') . ' EUR' : '-' }}
</div>
@if($item->latestPriceLog?->photo_path)
<div class="mt-2">
<p class="text-xs text-gray-500 mb-1">Kassenbon / Foto</p>
<a href="{{ $item->latestPriceLog->photoUrl() }}" target="_blank" rel="noopener noreferrer" class="block">
<img
src="{{ $item->latestPriceLog->photoUrl() }}"
alt="Kassenbon"
class="max-h-48 w-full max-w-xs rounded-lg border border-gray-200 object-contain bg-gray-50"
loading="lazy"
>
</a>
</div>
@endif
</div>
</form>
@include('shopping-list.partials.item-pencil-panel', [

View File

@ -112,6 +112,19 @@
bei {{ $item->latestPriceLog->store?->name ?: 'ohne Geschaeft' }}
({{ optional($item->latestPriceLog->logged_at)->format('d.m.Y H:i') }})
</div>
@if($item->latestPriceLog->photo_path)
<div class="mt-2">
<p class="text-xs text-gray-600 mb-1">Letztes Foto</p>
<a href="{{ $item->latestPriceLog->photoUrl() }}" target="_blank" rel="noopener noreferrer" class="block">
<img
src="{{ $item->latestPriceLog->photoUrl() }}"
alt="Kassenbon"
class="max-h-36 w-full rounded border border-gray-200 object-contain bg-gray-50"
loading="lazy"
>
</a>
</div>
@endif
@endif
<button
type="submit"

View File

@ -1,7 +1,9 @@
<?php
use App\Http\Controllers\ShoppingListController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ShoppingListController;
use App\Http\Controllers\ShoppingListMemberController;
use App\Http\Controllers\ShoppingListSwitchController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
@ -13,6 +15,10 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/shopping-items', [ShoppingListController::class, 'store'])->name('shopping-items.store');
Route::patch('/shopping-items/{shoppingItem}', [ShoppingListController::class, 'update'])->name('shopping-items.update');
Route::patch('/shopping-items/{shoppingItem}/toggle', [ShoppingListController::class, 'toggle'])->name('shopping-items.toggle');
Route::post('/shopping-lists/{shoppingList}/switch', ShoppingListSwitchController::class)->name('shopping-lists.switch');
Route::post('/shopping-lists/{shoppingList}/members', [ShoppingListMemberController::class, 'store'])->name('shopping-lists.members.store');
Route::delete('/shopping-lists/{shoppingList}/members/{user}', [ShoppingListMemberController::class, 'destroy'])->name('shopping-lists.members.destroy');
});
Route::middleware('auth')->group(function () {