Files
gamelog/docs/superpowers/plans/2026-05-30-follow-feed-notifications-sitemap-search-cache.md
T

430 lines
13 KiB
Markdown
Raw Normal View History

# GameLog Feature Batch Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add follow system, activity feed, notifications, sitemap, Scout search, and caching to the GameLog Laravel 11 app.
**Architecture:** All features hook into existing User/Game/UserGame/Review models. Migrations for follows/activities already ran. The nav (layouts/app.blade.php) handles all global UI additions. Scout uses the `database` driver for dev (SQLite).
**Tech Stack:** Laravel 13 + Tailwind + Alpine.js, SQLite dev, spatie/laravel-sitemap, laravel/scout
---
### Task 1: FollowController + Routes
**Files:**
- Create: `app/Http/Controllers/FollowController.php`
- Modify: `routes/web.php`
- [ ] Create FollowController with store/destroy returning JSON
```php
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\JsonResponse;
class FollowController extends Controller
{
public function store(User $user): JsonResponse
{
$me = auth()->user();
if ($me->id === $user->id) {
return response()->json(['error' => 'Cannot follow yourself'], 422);
}
if (!$me->following()->where('following_id', $user->id)->exists()) {
$me->following()->attach($user->id);
$user->notifications()->create([
'id' => \Illuminate\Support\Str::uuid(),
'type' => 'new_follower',
'data' => json_encode([
'follower_id' => $me->id,
'follower_username' => $me->username,
'follower_name' => $me->name,
]),
]);
}
return response()->json([
'following' => true,
'count' => $user->followers()->count(),
]);
}
public function destroy(User $user): JsonResponse
{
auth()->user()->following()->detach($user->id);
return response()->json([
'following' => false,
'count' => $user->followers()->count(),
]);
}
}
```
- [ ] Add routes to web.php (auth group)
```php
Route::middleware('auth')->group(function () {
Route::post('/users/{user:username}/follow', [\App\Http\Controllers\FollowController::class, 'store'])->name('users.follow');
Route::delete('/users/{user:username}/follow', [\App\Http\Controllers\FollowController::class, 'destroy'])->name('users.unfollow');
});
```
- [ ] Commit: `git commit -m "feat: add FollowController and routes"`
---
### Task 2: Notifications Migration + Model
**Files:**
- Create: `database/migrations/2026_05_30_000001_create_notifications_table.php`
- Create: `app/Models/Notification.php`
- [ ] Create notifications migration
```php
<?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('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('type', 100);
$table->json('data');
$table->timestamp('read_at')->nullable();
$table->timestamp('created_at')->nullable();
});
}
public function down(): void { Schema::dropIfExists('notifications'); }
};
```
- [ ] Create Notification model
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Notification extends Model
{
public $incrementing = false;
protected $keyType = 'string';
public $timestamps = false;
protected $fillable = ['id', 'user_id', 'type', 'data', 'read_at'];
protected $casts = ['data' => 'array', 'read_at' => 'datetime', 'created_at' => 'datetime'];
public function user(): BelongsTo { return $this->belongsTo(User::class); }
}
```
- [ ] Run migration: `php artisan migrate`
- [ ] Commit: `git commit -m "feat: add notifications table and model"`
---
### Task 3: Follow Button on users/show.blade.php
**Files:**
- Modify: `resources/views/users/show.blade.php`
- [ ] Add Alpine.js follow button block after `<div class="flex gap-2">` (replaces the lone "View Library" button container)
Replace the `<div class="flex gap-2">` block with:
```html
<div class="flex items-center gap-4">
{{-- Follower / Following counts --}}
<div class="flex gap-4 text-center">
<div>
<div class="text-lg font-bold text-white">{{ $user->followers()->count() }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Followers</div>
</div>
<div>
<div class="text-lg font-bold text-white">{{ $user->following()->count() }}</div>
<div class="text-xs text-slate-500 uppercase tracking-wide">Following</div>
</div>
</div>
<div class="flex gap-2">
@auth
@if(auth()->id() !== $user->id)
<div x-data="{
following: {{ auth()->user()->isFollowing($user) ? 'true' : 'false' }},
count: {{ $user->followers()->count() }},
async toggle() {
const method = this.following ? 'DELETE' : 'POST';
const res = await fetch('{{ route('users.follow', $user->username) }}', {
method,
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content, 'Accept': 'application/json' }
});
if (res.ok) {
const d = await res.json();
this.following = d.following;
this.count = d.count;
}
}
}">
<button @click="toggle()"
:class="following ? 'bg-white/10 text-slate-300 hover:bg-red-900/30 hover:text-red-400 border border-white/10' : 'bg-purple-600 hover:bg-purple-700 text-white'"
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
<span x-text="following ? 'Following' : 'Follow'"></span>
</button>
</div>
@endif
@endauth
<a href="{{ route('users.library', $user->username) }}"
class="px-4 py-2 border border-white/10 hover:border-white/20 text-slate-300 hover:text-white text-sm font-medium rounded-lg transition-colors">
View Library
</a>
</div>
</div>
```
- [ ] Commit: `git commit -m "feat: add follow button and follower counts to user profile"`
---
### Task 4: ActivityService + Hook into Models
**Files:**
- Create: `app/Services/ActivityService.php`
- Modify: `app/Models/UserGame.php`
- Modify: `app/Models/Review.php`
- [ ] Create ActivityService (thin wrapper around Activity::log)
```php
<?php
namespace App\Services;
use App\Models\Activity;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class ActivityService
{
public static function log(User $user, string $type, Model $subject, array $data = []): void
{
Activity::log($user->id, $type, $subject, $data);
}
}
```
- [ ] Add boot() hooks to UserGame model
```php
use App\Services\ActivityService;
protected static function boot(): void
{
parent::boot();
static::created(function (UserGame $ug) {
ActivityService::log($ug->user, 'added_to_library', $ug->game, ['status' => $ug->status]);
});
static::updated(function (UserGame $ug) {
if ($ug->isDirty('rating') && $ug->rating) {
ActivityService::log($ug->user, 'rated_game', $ug->game, ['rating' => $ug->rating]);
}
});
}
```
- [ ] Add to Review model boot() after the existing saved/deleted callbacks
```php
use App\Services\ActivityService;
// Inside the static::created callback (add this inside the boot method after existing code):
static::created(function (Review $review) {
ActivityService::log($review->user, 'reviewed_game', $review->game, ['rating' => $review->rating]);
});
```
- [ ] Commit: `git commit -m "feat: add ActivityService and log hooks on UserGame/Review"`
---
### Task 5: ActivityController + Routes + View
**Files:**
- Create: `app/Http/Controllers/ActivityController.php`
- Modify: `routes/web.php`
- Create: `resources/views/activity/index.blade.php`
- [ ] Create ActivityController
```php
<?php
namespace App\Http\Controllers;
use App\Models\Activity;
use Illuminate\Support\Facades\DB;
class ActivityController extends Controller
{
public function index()
{
$user = auth()->user();
$followingIds = $user->following()->pluck('users.id')->push($user->id);
$activities = Activity::with(['user', 'subject'])
->whereIn('user_id', $followingIds)
->latest('created_at')
->paginate(40);
return view('activity.index', compact('activities'));
}
public function global()
{
$activities = Activity::with(['user', 'subject'])
->latest('created_at')
->paginate(40);
return view('activity.index', compact('activities'));
}
}
```
- [ ] Add activity routes to web.php
```php
Route::get('/activity', [\App\Http\Controllers\ActivityController::class, 'index'])->name('activity.index')->middleware('auth');
Route::get('/activity/global', [\App\Http\Controllers\ActivityController::class, 'global'])->name('activity.global');
```
- [ ] Create activity/index.blade.php view (see full content below)
- [ ] Commit: `git commit -m "feat: add activity feed controller, routes, and view"`
---
### Task 6: NotificationController + Routes + View
**Files:**
- Create: `app/Http/Controllers/NotificationController.php`
- Modify: `routes/web.php`
- Create: `resources/views/notifications/index.blade.php`
- [ ] Create NotificationController
```php
<?php
namespace App\Http\Controllers;
use App\Models\Notification;
class NotificationController extends Controller
{
public function index()
{
$notifications = auth()->user()
->notifications()
->latest('created_at')
->limit(20)
->get();
auth()->user()->notifications()->whereNull('read_at')->update(['read_at' => now()]);
return view('notifications.index', compact('notifications'));
}
public function markRead(Notification $notification)
{
abort_if($notification->user_id !== auth()->id(), 403);
$notification->update(['read_at' => now()]);
return response()->json(['ok' => true]);
}
}
```
- [ ] Add notification routes to web.php (auth group)
```php
Route::get('/notifications', [\App\Http\Controllers\NotificationController::class, 'index'])->name('notifications.index')->middleware('auth');
Route::post('/notifications/{notification}/read', [\App\Http\Controllers\NotificationController::class, 'markRead'])->name('notifications.read')->middleware('auth');
```
- [ ] Add to User model: `public function notifications() { return $this->hasMany(Notification::class)->latest('created_at'); }`
- [ ] Create notifications/index.blade.php view
- [ ] Add bell icon to nav in layouts/app.blade.php
- [ ] Commit: `git commit -m "feat: add notifications controller, routes, view, and nav bell"`
---
### Task 7: Sitemap
**Files:**
- Run composer require
- Create: `app/Console/Commands/GenerateSitemapCommand.php`
- Modify: `routes/console.php` (Laravel 11 schedules here)
- Modify: `routes/web.php`
- Modify: `public/robots.txt`
- [ ] Install spatie/laravel-sitemap: `composer require spatie/laravel-sitemap`
- [ ] Create GenerateSitemapCommand
- [ ] Add weekly schedule in routes/console.php
- [ ] Add sitemap route in web.php
- [ ] Update robots.txt
- [ ] Commit: `git commit -m "feat: add sitemap generation command and route"`
---
### Task 8: Scout Full-Text Search
**Files:**
- Run composer require + vendor:publish
- Modify: `app/Models/Game.php`
- Modify: `app/Http/Controllers/GameController.php`
- Modify: `.env`
- [ ] Install Scout: `composer require laravel/scout && php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"`
- [ ] Add SCOUT_DRIVER=database to .env
- [ ] Add Searchable trait and toSearchableArray to Game model
- [ ] Update GameController@search to use Scout
- [ ] Commit: `git commit -m "feat: add Laravel Scout search to Game model"`
---
### Task 9: Performance Caching
**Files:**
- Modify: `app/Http/Controllers/HomeController.php`
- Modify: `app/Http/Controllers/GameController.php`
- Modify: `app/Models/Game.php`
- [ ] Wrap HomeController queries in Cache::remember
- [ ] Wrap GameController topRated/newReleases in Cache::remember with page key
- [ ] Flush caches in Game::recalculateRating()
- [ ] Add cache header to GameController@show response
- [ ] Commit: `git commit -m "feat: add file cache to home/top-rated/new-releases queries"`
---