Blok 4 · rozdział 11

Zdobycia i postęp Korony

Plan trzymany w sesji nie jest już potrzebny. Zdobycie to wiersz: użytkownik, szczyt, data i notatka. Jedna osoba zapisuje dany szczyt raz. Strona „Moja Korona” liczy postęp do 28.

Po tym rozdziale powinieneś umieć

  • dodać tabelę zdobycia z unikalną parą użytkownik–szczyt;
  • dopisać relacje do modelu User bez podmieniania klasy z Breeze;
  • zapisać zdobycie tylko dla zalogowanego i odrzucić duplikat;
  • pokazać licznik oraz listę brakujących szczytów;
  • zablokować usunięcie cudzego wiersza kodem 403.

1. Tabela i model

php artisan make:migration create_zdobycia_table
php artisan make:model Zdobycie
Schema::create('zdobycia', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignId('szczyt_id')->constrained('szczyty')->cascadeOnDelete();
    $table->date('data_zdobycia');
    $table->string('notatka')->nullable();
    $table->timestamps();
    $table->unique(['user_id', 'szczyt_id']);
});
php artisan migrate

Model Zdobycie:

protected $table = 'zdobycia';

protected $fillable = ['user_id', 'szczyt_id', 'data_zdobycia', 'notatka'];

protected function casts(): array
{
    return [
        'data_zdobycia' => 'date',
    ];
}

W Laravelu 13 casty zapisuje się metodą casts(). Gdy generator zostawił właściwość $casts, możesz zostać przy niej — efekt dla daty jest ten sam.

Data zdobycia a plan z rozdziału 6 Plan dotyczył przyszłości (after_or_equal:today). Zdobycie dotyczy dnia, który już był albo jest dzisiaj: before_or_equal:today.

2. Relacje z kontem

W Zdobycie:

public function user(): BelongsTo
{
    return $this->belongsTo(User::class);
}

public function szczyt(): BelongsTo
{
    return $this->belongsTo(Szczyt::class);
}

W klasie User (tej z Breeze — dopisujesz metodę, nie tworzysz drugiej klasy User):

public function zdobycia(): HasMany
{
    return $this->hasMany(Zdobycie::class);
}

W Szczyt ta sama metoda zdobycia(): HasMany. Pamiętaj o use na górze plików.

users

Jedno konto ma wiele zdobyć.

N : M
przez zdobycia

szczyty

Jeden szczyt ma wielu zdobywców. Data leży w tabeli środkowej.

3. Zapis na karcie szczytu

php artisan make:controller ZdobycieController
Route::post('/szczyty/{szczyt}/zdobycia', [ZdobycieController::class, 'store'])
    ->middleware('auth')
    ->name('zdobycia.store');
public function store(Request $request, Szczyt $szczyt)
{
    $dane = $request->validate([
        'data_zdobycia' => ['required', 'date', 'before_or_equal:today'],
        'notatka' => ['nullable', 'string', 'max:200'],
    ], [
        'data_zdobycia.before_or_equal' => 'Data nie może być z przyszłości.',
    ]);

    $juzJest = $request->user()
        ->zdobycia()
        ->where('szczyt_id', $szczyt->id)
        ->exists();

    if ($juzJest) {
        return back()->withErrors([
            'data_zdobycia' => 'Ten szczyt jest już w Twojej koronie.',
        ]);
    }

    $request->user()->zdobycia()->create([
        'szczyt_id' => $szczyt->id,
        'data_zdobycia' => $dane['data_zdobycia'],
        'notatka' => $dane['notatka'] ?? null,
    ]);

    return redirect()
        ->route('szczyty.show', $szczyt)
        ->with('status', 'Zapisano zdobycie.');
}

create na relacji $request->user()->zdobycia() sam uzupełnia user_id. Nie bierz identyfikatora użytkownika z ukrytego pola formularza — przeglądarka dałaby się podmienić.

Na karcie szczytu:

@auth
    <form class="formularz" method="post" action="{{ route('zdobycia.store', $szczyt) }}">
        @csrf
        <label>Data wejścia
            <input type="date" name="data_zdobycia" value="{{ old('data_zdobycia') }}">
        </label>
        @error('data_zdobycia')
            <p class="blad">{{ $message }}</p>
        @enderror
        <label>Notatka
            <input type="text" name="notatka" value="{{ old('notatka') }}">
        </label>
        <button type="submit">Zapisz zdobycie</button>
    </form>
@else
    <p><a href="{{ route('login') }}">Zaloguj się</a>, żeby zapisać ten szczyt.</p>
@endauth
Middleware auth Gość, który zna adres POST, i tak dostanie przekierowanie na logowanie. Samo @auth w HTML tego nie załatwia — chowa przycisk, nie zamyka trasy.

4. Moja Korona

Route::get('/moja-korona', [ZdobycieController::class, 'moja'])
    ->middleware('auth')
    ->name('korona.moja');
public function moja()
{
    $user = auth()->user();
    $zdobyte = $user->zdobycia()->count();
    $wszystkie = Szczyt::query()->count();

    $brakujace = Szczyt::query()
        ->with('pasmo')
        ->whereDoesntHave('zdobycia', function ($zapytanie) use ($user) {
            $zapytanie->where('user_id', $user->id);
        })
        ->orderByDesc('wysokosc_m')
        ->get();

    return view('korona.moja', [
        'zdobyte' => $zdobyte,
        'wszystkie' => $wszystkie,
        'brakujace' => $brakujace,
    ]);
}

Widok resources/views/korona/moja.blade.php rozszerza układ korona. Pasek:

<p>{{ $zdobyte }} / {{ $wszystkie }}</p>
<div class="postep">
    <span style="width: {{ $wszystkie ? round($zdobyte / $wszystkie * 100) : 0 }}%"></span>
</div>

Pod spodem @forelse ($brakujace as $szczyt) i komponent karty. W menu dodaj link route('korona.moja') wewnątrz @auth.

5. Cudze zdobycie

Route::delete('/zdobycia/{zdobycie}', [ZdobycieController::class, 'destroy'])
    ->middleware('auth')
    ->name('zdobycia.destroy');
public function destroy(Zdobycie $zdobycie)
{
    abort_unless($zdobycie->user_id === auth()->id(), 403);

    $zdobycie->delete();

    return back()->with('status', 'Usunięto zdobycie.');
}

Formularz usuwania przy własnym szczycie używa @method('DELETE') i @csrf. HTML zna tylko GET i POST, więc Laravel czyta ukryte pole metody.

403 a 404 Cudzy adres kasowania kończy się zakazem (403), o ile wiersz istnieje i użytkownik jest zalogowany. Sprawdź to drugim kontem. Unikat w tabeli i tak nie pozwoli wstawić tego samego szczytu dwa razy temu samemu użytkownikowi.
Commit

Gdy zalogowany zapisuje zdobycie, a drugie konto nie widzi cudzych w swoim liczniku:

git add .
git status
git commit -m "Zapis zdobyć"

Przed commitem git status nie pokazuje .env.