feat: Phase 1 - Redis config, Dashboard cache, Report cache, N+1 fixes, observers

- Add predis/predis for Redis support
- Configure cache/queue defaults to redis in config + .env.example
- Create DashboardController with 60s cache for dashboard queries
- Add cache (5min) to ReportController::generate() and preview()
- Add FeatureObserver, InspectionObserver, IssueObserver for cache invalidation
- Fix N+1 in ProjectMap (eager load template, images), TaskManager (subtasks.parentTask)
- Register observers in AppServiceProvider

Tests: 101 passing (319 assertions)
This commit is contained in:
Javier Braña
2026-08-31 09:44:33 +02:00
parent 90630379fb
commit 78d1dbf45a
16 changed files with 1029 additions and 49 deletions
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Models\Issue;
use App\Models\IssueTask;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class DashboardController extends Controller
{
public function index()
{
$user = Auth::user();
$cacheKey = "dashboard:{$user->id}:v1";
$data = Cache::remember($cacheKey, 60, function () use ($user) {
$projects = Project::accessibleBy($user)
->with(['phases' => fn ($q) => $q->select('id', 'project_id', 'progress_percent')])
->orderBy('name')->take(8)->get();
$projectsCount = Project::accessibleBy($user)->count();
$myTasks = IssueTask::where('assigned_to', $user->id)
->where('is_done', false)
->with('issue.project')
->orderByRaw('due_date IS NULL, due_date ASC')
->take(8)->get();
$myTasksFromTasks = Task::where('assigned_to', $user->id)
->where('status', '!=', 'completed')
->with('project')
->orderByRaw('due_date IS NULL, due_date ASC')
->take(8)->get();
$myTasksCount = IssueTask::where('assigned_to', $user->id)->where('is_done', false)->count()
+ Task::where('assigned_to', $user->id)->where('status', '!=', 'completed')->count();
$myIssues = Issue::where('assigned_to', $user->id)
->whereIn('status', ['open', 'in_review'])
->with('project')
->latest()->take(6)->get();
$myIssuesCount = Issue::where('assigned_to', $user->id)->whereIn('status', ['open', 'in_review'])->count();
$notifications = $user->notifications()->latest()->take(6)->get();
$unreadCount = $user->unreadNotifications()->count();
return compact(
'user', 'projects', 'projectsCount', 'myTasks', 'myTasksFromTasks', 'myTasksCount',
'myIssues', 'myIssuesCount', 'notifications', 'unreadCount'
);
});
return view('dashboard', $data);
}
}
+13 -4
View File
@@ -8,6 +8,7 @@ use App\Models\Project;
use App\Services\ReportGenerator;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Maatwebsite\Excel\Facades\Excel;
class ReportController extends Controller
@@ -32,8 +33,12 @@ class ReportController extends Controller
$this->authorizeProjectAccess($project);
$filters = ReportFilters::fromRequest($request->all());
$generator = new ReportGenerator($project, $filters);
$data = $generator->generate();
$cacheKey = "report:{$project->id}:".md5(json_encode($filters->toArray()));
$data = Cache::remember($cacheKey, 300, function () use ($project, $filters) {
$generator = new ReportGenerator($project, $filters);
return $generator->generate();
});
$format = $filters->format;
@@ -53,8 +58,12 @@ class ReportController extends Controller
$this->authorizeProjectAccess($project);
$filters = ReportFilters::fromRequest($request->all());
$generator = new ReportGenerator($project, $filters);
$data = $generator->generate();
$cacheKey = "report:{$project->id}:".md5(json_encode($filters->toArray()));
$data = Cache::remember($cacheKey, 300, function () use ($project, $filters) {
$generator = new ReportGenerator($project, $filters);
return $generator->generate();
});
return view('reports.partials._preview', $data);
}
+2 -2
View File
@@ -109,7 +109,7 @@ class ProjectMap extends Component
$this->authorizeProjectAccess();
$this->phases = $project->phases()->with([
'layers' => fn ($q) => $q->withCount('features'),
'layers' => fn ($q) => $q->withCount('features')->with(['features.template', 'features.images']),
'layers.features',
'layers.features.images',
])->get();
@@ -124,7 +124,7 @@ class ProjectMap extends Component
$this->allFeatures = Feature::whereHas('layer.phase', function ($q) use ($project) {
$q->where('project_id', $project->id);
})->with(['layer.phase', 'template'])->get();
})->with(['layer.phase', 'template', 'images'])->get();
$this->allInspections = Inspection::where('project_id', $project->id)
->with(['feature.layer.phase', 'template', 'user'])
+1 -1
View File
@@ -158,7 +158,7 @@ class TaskManager extends Component
$query->orderBy('order')->orderBy('created_at', 'desc');
}
$tasks = $query->paginate($this->perPage);
$tasks = $query->with(['project', 'phase', 'assignee', 'creator', 'subtasks.parentTask'])->paginate($this->perPage);
// Filter options
$assignees = User::whereHas('assignedTasks', function ($q) use ($user) {
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Observers;
use App\Models\Feature;
use Illuminate\Support\Facades\Cache;
class FeatureObserver
{
public function saved(Feature $feature): void
{
$this->clearReportCache($feature->project_id);
}
public function deleted(Feature $feature): void
{
$this->clearReportCache($feature->project_id);
}
private function clearReportCache(?int $projectId): void
{
if ($projectId) {
Cache::tags(["report:project:{$projectId}"])->flush();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Observers;
use App\Models\Inspection;
use Illuminate\Support\Facades\Cache;
class InspectionObserver
{
public function saved(Inspection $inspection): void
{
$this->clearReportCache($inspection->project_id);
}
public function deleted(Inspection $inspection): void
{
$this->clearReportCache($inspection->project_id);
}
private function clearReportCache(?int $projectId): void
{
if ($projectId) {
Cache::tags(["report:project:{$projectId}"])->flush();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Observers;
use App\Models\Issue;
use Illuminate\Support\Facades\Cache;
class IssueObserver
{
public function saved(Issue $issue): void
{
$this->clearReportCache($issue->project_id);
}
public function deleted(Issue $issue): void
{
$this->clearReportCache($issue->project_id);
}
private function clearReportCache(?int $projectId): void
{
if ($projectId) {
Cache::tags(["report:project:{$projectId}"])->flush();
}
}
}
+11
View File
@@ -2,8 +2,14 @@
namespace App\Providers;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\Issue;
use App\Models\Project;
use App\Models\Task;
use App\Observers\FeatureObserver;
use App\Observers\InspectionObserver;
use App\Observers\IssueObserver;
use App\Policies\TaskPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
@@ -25,6 +31,11 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
// Register model observers for cache invalidation
Feature::observe(FeatureObserver::class);
Inspection::observe(InspectionObserver::class);
Issue::observe(IssueObserver::class);
// Re-register model bindings so they persist in route cache
Route::model('project', Project::class);