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
+2 -2
View File
@@ -35,9 +35,9 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
QUEUE_CONNECTION=redis
CACHE_STORE=database
CACHE_STORE=redis
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
@@ -0,0 +1,792 @@
# ConstruProgress Improvement Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Improve performance, security, real-time capabilities, and developer experience of the ConstruProgress Laravel + Livewire application.
**Architecture:** Incremental enhancements to existing Laravel 11 + Livewire 3 + Spatie Permissions stack. No breaking changes to API.
**Tech Stack:** Laravel 12, Livewire 3.6, Alpine.js, SQLite (tests) / MySQL (prod), PHP 8.2+, Pest/PHPUnit, Laravel Pint.
---
## Phase 1: Performance & Caching (High ROI, Low Risk)
### Task 1.1: Install and configure Redis
**Objective:** Add Redis for caching and queue worker
**Files:**
- Create: `config/redis.php` (if missing)
- Modify: `.env.example` — add REDIS_* vars
- Modify: `config/cache.php` — set default to redis
- Modify: `config/queue.php` — set default to redis
- Modify: `composer.json` — add `predis/predis` or `phpredis` ext
**Step 1: Add Redis dependency**
```bash
cd /mnt/c/xampp/htdocs/construprogress && /mnt/c/xampp/php/php.exe composer require predis/predis --no-interaction
```
**Step 2: Configure cache and queue**
```php
// config/cache.php
'default' => env('CACHE_DRIVER', 'redis'),
// config/queue.php
'default' => env('QUEUE_CONNECTION', 'redis'),
```
**Step 3: Verify**
```bash
/mnt/c/xampp/php/php.exe artisan config:clear && /mnt/c/xampp/php/php.exe artisan test --filter="Cache"
```
**Commit:** `feat: add Redis for caching and queues`
---
### Task 1.2: Cache ProjectDashboard queries
**Objective:** Cache expensive dashboard queries (projects, tasks, issues, notifications)
**Files:**
- Modify: `routes/web.php:59-95` (dashboard closure) → move to `App\Http\Controllers\DashboardController`
- Create: `app/Http/Controllers/DashboardController.php`
- Modify: `resources/views/dashboard.blade.php` (if exists)
**Step 1: Create controller with cache**
```php
// app/Http/Controllers/DashboardController.php
public function index()
{
$user = Auth::user();
$cacheKey = "dashboard:{$user->id}:v1";
$data = Cache::remember($cacheKey, 60, function () use ($user) {
// ... existing query logic
});
return view('dashboard', $data);
}
```
**Step 2: Invalidate on relevant events**
```php
// In Task/Issue/Project observers or model events
Cache::forget("dashboard:{$user->id}:v1");
```
**Step 3: Test**
```bash
/mnt/c/xampp/php/php.exe artisan test --filter="Dashboard"
```
**Commit:** `perf: cache dashboard queries with 60s TTL`
---
### Task 1.3: Cache ReportController generate/preview
**Objective:** Cache report generation (HTML + Excel) per project + filters
**Files:**
- Modify: `app/Http/Controllers/ReportController.php` (generate, preview methods)
- Modify: `app/Services/ReportGenerator.php` — add cache key generation
**Step 1: Add cache to ReportController::generate()**
```php
$cacheKey = "report:{$project->id}:".md5(json_encode($filters->toArray()));
$data = Cache::remember($cacheKey, 300, fn () => $generator->generate());
```
**Step 2: Invalidate on progress/inspection/issue changes**
```php
// Feature, Inspection, Issue observers
Cache::tags(["report:project:{$project->id}"])->flush();
```
**Commit:** `perf: cache report generation with 5min TTL`
---
### Task 1.4: Optimize N+1 queries in Livewire components
**Objective:** Fix N+1 in ProjectMap, TaskManager, IssueManager
**Files:**
- Modify: `app/Livewire/Projects/ProjectMap.php` (mount, loadTemplates, selectFeature)
- Modify: `app/Livewire/Tasks/TaskManager.php` (render)
- Modify: `app/Livewire/Issues/IssueManager.php` (render)
**Step 1: Audit current eager loading**
```bash
grep -n "with(" app/Livewire/Projects/ProjectMap.php
grep -n "with(" app/Livewire/Tasks/TaskManager.php
grep -n "with(" app/Livewire/Issues/IssueManager.php
```
**Step 2: Add missing relationships**
```php
// ProjectMap mount()
->with(['layers.features.template', 'layers.features.inspections', 'layers.features.issues'])
// TaskManager render()
->with(['project', 'phase', 'assignee', 'creator', 'subtasks.parentTask'])
// IssueManager render()
->with(['project', 'assignee', 'reporter', 'feature.layer.phase', 'checklistItems', 'comments.user'])
```
**Step 3: Verify with Laravel Debugbar or query log**
```bash
/mnt/c/xampp/php/php.exe artisan test --filter="MapTablesTest|IssuesTablePageTest"
```
**Commit:** `perf: fix N+1 queries in Map, TaskManager, IssueManager`
---
## Phase 2: Real-time Notifications (High Impact)
### Task 2.1: Install Laravel Reverb
**Objective:** Add WebSocket server for real-time events
**Files:**
- Modify: `composer.json` — add `laravel/reverb`
- Create: `config/reverb.php` (via vendor:publish)
- Modify: `.env.example` — REVERB_* vars
- Modify: `bootstrap.js` / `resources/js/app.js` — Echo + Reverb client
**Step 1: Install**
```bash
/mnt/c/xampp/php/php.exe composer require laravel/reverb --no-interaction
/mnt/c/xampp/php/php.exe artisan reverb:install
npm install --save-dev laravel-echo pusher-js
```
**Step 2: Configure broadcasting**
```php
// config/broadcasting.php
'default' => env('BROADCAST_DRIVER', 'reverb'),
```
**Step 3: Update frontend**
```javascript
// resources/js/app.js
import Echo from 'laravel-echo';
window.Pusher = require('pusher-js');
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT,
wssPort: import.meta.env.VITE_REVERB_PORT,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
```
**Commit:** `feat: add Laravel Reverb for real-time`
---
### Task 2.2: Broadcast notifications via Reverb
**Objective:** Replace polling with real-time notification delivery
**Files:**
- Modify: `app/Notifications/*.php` — implement `ShouldBroadcast` + `broadcastOn()`
- Modify: `resources/views/layouts/navigation.blade.php` (or notification bell component) — listen for Echo events
- Create: `resources/js/components/NotificationBell.vue` (or Alpine component)
**Step 1: Update base notification**
```php
// app/Notifications/Notification.php (or trait)
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
class Notification implements ShouldBroadcast
{
public function broadcastOn(): array
{
return [new PrivateChannel('user.'.$this->notifiable->id)];
}
}
```
**Step 2: Frontend listener**
```html
<!-- In notification bell component -->
<script>
document.addEventListener('livewire:load', () => {
window.Echo?.private(`user.{{ auth()->id() }}`)
.notification((notification) => {
// Update badge count, prepend to dropdown
Livewire.dispatch('notificationReceived', notification);
});
});
</script>
```
**Step 3: Test with two browser tabs**
```bash
# Start reverb
/mnt/c/xampp/php/php.exe artisan reverb:start --debug
# Trigger notification via test
/mnt/c/xampp/php/php.exe artisan test --filter="IssuesEnhancementsTest::assigning a task notifies"
```
**Commit:** `feat: real-time notifications via Reverb`
---
### Task 2.3: Broadcast task/issue updates to ProjectMap
**Objective:** Live updates on map when features change (progress, status, inspections)
**Files:**
- Modify: `app/Livewire/Projects/ProjectMap.php` — listen for broadcast events
- Modify: `app/Models/Feature.php` — broadcast on update
- Modify: `app/Models/Inspection.php` — broadcast on create
**Step 1: Broadcast feature changes**
```php
// Feature model
protected $dispatchesEvents = [
'updated' => FeatureUpdated::class,
];
// Event
class FeatureUpdated implements ShouldBroadcast
{
public function broadcastOn(): array
{
return [new PrivateChannel('project.'.$this->feature->project_id)];
}
}
```
**Step 2: Listen in ProjectMap**
```php
#[On('echo:project.{projectId},FeatureUpdated')]
public function handleFeatureUpdated($event)
{
$this->allFeatures = $this->allFeatures->map(fn ($f) =>
$f->id === $event['feature']['id'] ? (object)$event['feature'] : $f
);
$this->dispatch('featureUpdated', $event['feature']);
}
```
**Commit:** `feat: live map updates via Reverb`
---
## Phase 3: Validation & Security Hardening
### Task 3.1: Create FormRequests for critical Livewire forms
**Objective:** Centralize validation + authorization for TaskForm, IssueForm, InspectionForm
**Files:**
- Create: `app/Http/Requests/TaskStoreRequest.php`
- Create: `app/Http/Requests/TaskUpdateRequest.php`
- Create: `app/Http/Requests/IssueStoreRequest.php`
- Create: `app/Http/Requests/IssueUpdateRequest.php`
- Create: `app/Http/Requests/InspectionStoreRequest.php`
- Create: `app/Http/Requests/InspectionUpdateRequest.php`
- Modify: `app/Livewire/Tasks/TaskForm.php` — use FormRequest
- Modify: `app/Livewire/Issues/IssueForm.php` — use FormRequest
- Modify: `app/Livewire/Projects/ProjectMap.php` (inspection methods) — use FormRequest
**Step 1: Generate requests**
```bash
/mnt/c/xampp/php/php.exe artisan make:request TaskStoreRequest
/mnt/c/xampp/php/php.exe artisan make:request TaskUpdateRequest
# ... repeat for Issue, Inspection
```
**Step 2: Implement rules + authorize**
```php
// TaskStoreRequest
public function authorize(): bool
{
return $this->user()->can('create tasks', $this->route('project'));
}
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'phase_id' => 'nullable|exists:phases,id',
'status' => 'required|in:pending,in_progress,completed,cancelled',
// ...
];
}
```
**Step 3: Use in Livewire**
```php
// TaskForm::save()
$validated = app(TaskStoreRequest::class)->validated();
// or for update
$validated = app(TaskUpdateRequest::class)->validated();
```
**Step 4: Test**
```bash
/mnt/c/xampp/php/php.exe artisan test --filter="TaskForm|IssueForm|InspectionForm"
```
**Commit:** `refactor: FormRequests for Task, Issue, Inspection forms`
---
### Task 3.2: Add rate limiting to all API routes
**Objective:** Protect bundle, templates, projects endpoints
**Files:**
- Modify: `routes/api.php` — add throttle middleware to all routes
**Step 1: Update routes**
```php
Route::middleware(['auth:sanctum', 'ability:mobile-sync', 'throttle:120,1'])->group(function () {
Route::get('projects', [ProjectApiController::class, 'index']);
Route::get('projects/{project}/bundle', [ProjectApiController::class, 'bundle']);
Route::get('templates', [ProjectApiController::class, 'templates']);
});
Route::middleware(['auth:sanctum', 'ability:mobile-sync', 'throttle:60,1'])->group(function () {
Route::post('sync', [SyncController::class, 'sync']);
});
Route::middleware(['auth:sanctum', 'ability:mobile-sync', 'throttle:120,1'])->group(function () {
Route::post('media', [MediaController::class, 'upload']);
});
```
**Step 2: Test rate limit**
```bash
/mnt/c/xampp/php/php.exe artisan test --filter="MobileApiTest"
```
**Commit:** `security: rate limit all mobile API endpoints`
---
### Task 3.3: Add mass assignment protection audit
**Objective:** Ensure all models use `$fillable` (not `$guarded = []`)
**Files:**
- Check: all `app/Models/*.php`
- Modify: any with `$guarded = ['*']` or missing `$fillable`
**Step 1: Audit**
```bash
grep -r "guarded" app/Models/ --include="*.php"
grep -r "fillable" app/Models/ --include="*.php"
```
**Step 2: Fix any gaps**
```php
// Example fix
protected $fillable = ['name', 'email', 'status']; // explicit list
// NOT: protected $guarded = ['id']; // or empty
```
**Commit:** `security: audit mass assignment protection`
---
## Phase 4: Developer Experience & Testing
### Task 4.1: Add Laravel Pint to CI (already done locally)
**Objective:** Ensure code style enforced in pipeline
**Files:**
- Create: `.github/workflows/pint.yml` (or GitLab CI equivalent)
**Step 1: Create workflow**
```yaml
# .github/workflows/pint.yml
name: Code Style
on: [push, pull_request]
jobs:
pint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '8.2' }
- run: composer install --prefer-dist --no-progress
- run: ./vendor/bin/pint --test
```
**Commit:** `ci: add Pint code style check`
---
### Task 4.2: Add mutation testing (Infection)
**Objective:** Measure test quality beyond coverage
**Files:**
- Modify: `composer.json` — add `infection/infection` dev dep
- Create: `infection.json5` config
**Step 1: Install**
```bash
/mnt/c/xampp/php/php.exe composer require --dev infection/infection --no-interaction
```
**Step 2: Configure**
```json5
// infection.json5
{
"source": {
"directories": ["app"]
},
"mutators": {
"@default": true
},
"testFramework": "phpunit",
"testFrameworkOptions": "--testdox",
"minMsi": 80,
"minCoveredMsi": 80
}
```
**Step 3: Run baseline**
```bash
/mnt/c/xampp/php/php.exe vendor/bin/infection --configuration=infection.json5
```
**Commit:** `test: add mutation testing with Infection`
---
### Task 4.3: Add browser tests (Laravel Dusk)
**Objective:** Test critical user flows (login, create project, map interaction, report generation)
**Files:**
- Modify: `composer.json` — add `laravel/dusk` dev dep
- Create: `tests/Browser/*.php`
- Modify: `.env.dusk.local` (or CI config)
**Step 1: Install**
```bash
/mnt/c/xampp/php/php.exe composer require --dev laravel/dusk --no-interaction
/mnt/c/xampp/php/php.exe artisan dusk:install
```
**Step 2: Write critical flow tests**
```php
// tests/Browser/ProjectWorkflowTest.php
public function test_user_can_create_project_and_add_feature()
{
$this->browse(function (Browser $browser) {
$browser->loginAs(User::factory()->create())
->visit('/projects/create')
->type('name', 'Test Project')
->press('Guardar')
->assertPathIs('/projects/1')
->visit('/projects/1/map')
->click('@add-feature-button')
->type('name', 'Test Feature')
->press('Guardar')
->assertSee('Test Feature');
});
}
```
**Commit:** `test: add Dusk browser tests for critical flows`
---
## Phase 5: Feature Gaps (Medium Priority)
### Task 5.1: Scheduled report email job
**Objective:** Daily/weekly email with project report to stakeholders
**Files:**
- Create: `app/Jobs/SendScheduledReport.php`
- Modify: `routes/console.php` — schedule job
- Create: `resources/views/emails/scheduled-report.blade.php`
- Modify: `app/Console/Commands/SendScheduledReports.php` (or use Schedule directly)
**Step 1: Create job**
```php
// app/Jobs/SendScheduledReport.php
public function handle()
{
$projects = Project::whereHas('users', fn ($q) => $q->where('receive_reports', true))->get();
foreach ($projects as $project) {
$users = $project->users()->where('receive_reports', true)->get();
foreach ($users as $user) {
Mail::to($user)->send(new ScheduledReportMail($project, $user));
}
}
}
```
**Step 2: Schedule**
```php
// routes/console.php
Schedule::job(new SendScheduledReport)->dailyAt('08:00');
```
**Commit:** `feat: scheduled report emails`
---
### Task 5.2: Global search (Scout + Meilisearch/Typesense)
**Objective:** Cross-project search for features, issues, tasks, inspections
**Files:**
- Modify: `composer.json` — add `laravel/scout`, `meilisearch/meilisearch-php`
- Create: `config/scout.php` (vendor:publish)
- Modify: Models (Feature, Issue, Task, Inspection) — `Searchable` trait
- Create: `app/Livewire/Common/GlobalSearch.php`
- Modify: Navigation layout — add search input
**Step 1: Install**
```bash
/mnt/c/xampp/php/php.exe composer require laravel/scout meilisearch/meilisearch-php --no-interaction
/mnt/c/xampp/php/php.exe artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
```
**Step 2: Make models searchable**
```php
// Feature.php
use Laravel\Scout\Searchable;
class Feature extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'project_id' => $this->project_id,
'status' => $this->status,
];
}
}
```
**Step 3: Index & search**
```bash
/mnt/c/xampp/php/php.exe artisan scout:import "App\Models\Feature"
```
**Commit:** `feat: global search with Scout + Meilisearch`
---
### Task 5.3: PWA offline support (Workbox)
**Objective:** Restore offline capability for field workers
**Files:**
- Create: `public/sw.js` (Workbox-generated)
- Modify: `vite.config.js` — add `vite-plugin-pwa`
- Modify: `resources/js/app.js` — register SW
- Modify: `OfflineSyncController` — ensure compatibility
**Step 1: Add PWA plugin**
```bash
npm install --save-dev vite-plugin-pwa workbox-window
```
**Step 2: Configure**
```javascript
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'ConstruProgress',
short_name: 'CP',
theme_color: '#2563eb',
icons: [...]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/.*\/api\/v1\/.*/,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache', expiration: { maxEntries: 100, maxAgeSeconds: 86400 } }
}
]
}
})
]
});
```
**Commit:** `feat: PWA offline support with Workbox`
---
## Phase 6: Code Organization (Tech Debt)
### Task 6.1: Split ProjectMap into traits/components
**Objective:** Reduce 800-line component into manageable pieces
**Files:**
- Create: `app/Livewire/Projects/Traits/MapLayers.php`
- Create: `app/Livewire/Projects/Traits/MapInspections.php`
- Create: `app/Livewire/Projects/Traits/MapIssues.php`
- Create: `app/Livewire/Projects/Traits/MapFeatures.php`
- Modify: `app/Livewire/Projects/ProjectMap.php` — use traits
**Step 1: Extract layers logic**
```php
// app/Livewire/Projects/Traits/MapLayers.php
trait MapLayers
{
public $phases;
public $activeLayers = [];
public $showLayerModal = false;
// ... layer methods
}
```
**Step 2: Use in ProjectMap**
```php
class ProjectMap extends Component
{
use MapLayers, MapInspections, MapIssues, MapFeatures;
// ... only core map logic remains
}
```
**Step 3: Test**
```bash
/mnt/c/xampp/php/php.exe artisan test --filter="MapTablesTest"
```
**Commit:** `refactor: split ProjectMap into traits`
---
### Task 6.2: Extract ReportGenerator into smaller services
**Objective:** Separate data aggregation from export formatting
**Files:**
- Create: `app/Services/Report/DataAggregator.php`
- Create: `app/Services/Report/HtmlExporter.php`
- Create: `app/Services/Report/ExcelExporter.php`
- Modify: `app/Services/ReportGenerator.php` — delegate to services
- Modify: `app/Exports/ProjectReportExport.php` — use HtmlExporter/ExcelExporter
**Step 1: Create DataAggregator**
```php
// app/Services/Report/DataAggregator.php
class DataAggregator
{
public function aggregate(Project $project, ReportFilters $filters): array
{
// ... all the data fetching logic from ReportGenerator::generate()
}
}
```
**Step 2: Create exporters**
```php
// app/Services/Report/HtmlExporter.php
class HtmlExporter
{
public function export(array $data): string
{
return view('reports.complete', $data)->render();
}
}
// app/Services/Report/ExcelExporter.php
class ExcelExporter
{
public function export(Project $project, ReportFilters $filters, array $data): BinaryFileResponse
{
return Excel::download(new ProjectReportExport($project, $filters, $data), $filename);
}
}
```
**Step 3: Simplify ReportGenerator**
```php
// app/Services/ReportGenerator.php
public function generate(): array
{
return (new DataAggregator)->aggregate($this->project, $this->filters);
}
```
**Commit:** `refactor: split ReportGenerator into Aggregator + Exporters`
---
## Verification Checklist
After all phases:
- [ ] All 101 existing tests still pass
- [ ] New tests added for each feature (target: +30 tests)
- [ ] API routes unchanged (8 routes, 24 tests pass)
- [ ] Pint passes on entire codebase
- [ ] Mutation score ≥ 80%
- [ ] Dusk tests pass for critical flows
- [ ] Reverb + Echo working in local dev
- [ ] Dashboard load time < 500ms (cached)
- [ ] Report generation < 2s (cached)
---
## Risks & Tradeoffs
| Risk | Mitigation |
|------|------------|
| Reverb adds infrastructure complexity | Start with single-server; scale later |
| Cache invalidation bugs | Use model observers + cache tags; test thoroughly |
| Breaking existing Livewire components | TDD each refactor; run full test suite after each |
| Meilisearch adds external dependency | Can use Typesense or database driver as fallback |
| Dusk requires Chrome in CI | Use `laravel/dusk` GitHub Action with Chrome |
---
## Open Questions
1. **Redis hosting:** Self-hosted, Docker, or managed (Redis Cloud, Upstash)?
2. **Reverb hosting:** Same server as app, or separate?
3. **Search engine:** Meilisearch (simpler) vs Typesense (better filtering)?
4. **PWA strategy:** Full offline-first or just cache-first for assets?
5. **Report scheduling:** Per-user preferences or project-level only?
---
## Execution Order (Dependencies)
```
Phase 1 (independent) → Phase 2 (needs Redis) → Phase 3 (independent)
Phase 4 (needs Phase 1 cache) ← Phase 5 (needs Phase 2 Reverb)
Phase 6 (refactor, anytime after tests stable)
```
**Recommended start:** Task 1.1 (Redis) → 1.2 (Dashboard cache) → 2.1 (Reverb) → 3.1 (FormRequests)
@@ -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);
+1
View File
@@ -18,6 +18,7 @@
"maatwebsite/excel": "*",
"mansoor/blade-lets-icons": "^1.0",
"phayes/geophp": "^1.2",
"predis/predis": "^1.1",
"rappasoft/laravel-livewire-tables": "^3.7",
"spatie/laravel-permission": "^6.25"
},
Generated
+67 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "45553317b713050f78b4233c204790f9",
"content-hash": "59f27ca027f68b2cd10cb3d30bae901b",
"packages": [
{
"name": "blade-ui-kit/blade-heroicons",
@@ -3870,6 +3870,72 @@
],
"time": "2025-12-27T19:41:33+00:00"
},
{
"name": "predis/predis",
"version": "v1.1.10",
"source": {
"type": "git",
"url": "https://github.com/predis/predis.git",
"reference": "a2fb02d738bedadcffdbb07efa3a5e7bd57f8d6e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/predis/predis/zipball/a2fb02d738bedadcffdbb07efa3a5e7bd57f8d6e",
"reference": "a2fb02d738bedadcffdbb07efa3a5e7bd57f8d6e",
"shasum": ""
},
"require": {
"php": ">=5.3.9"
},
"require-dev": {
"phpunit/phpunit": "~4.8"
},
"suggest": {
"ext-curl": "Allows access to Webdis when paired with phpiredis",
"ext-phpiredis": "Allows faster serialization and deserialization of the Redis protocol"
},
"type": "library",
"autoload": {
"psr-4": {
"Predis\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Daniele Alessandri",
"email": "suppakilla@gmail.com",
"homepage": "http://clorophilla.net",
"role": "Creator & Maintainer"
},
{
"name": "Till Krüss",
"homepage": "https://till.im",
"role": "Maintainer"
}
],
"description": "Flexible and feature-complete Redis client for PHP and HHVM",
"homepage": "http://github.com/predis/predis",
"keywords": [
"nosql",
"predis",
"redis"
],
"support": {
"issues": "https://github.com/predis/predis/issues",
"source": "https://github.com/predis/predis/tree/v1.1.10"
},
"funding": [
{
"url": "https://github.com/sponsors/tillkruss",
"type": "github"
}
],
"time": "2022-01-05T17:46:08+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -15,7 +15,7 @@ return [
|
*/
'default' => env('CACHE_STORE', 'database'),
'default' => env('CACHE_STORE', 'redis'),
/*
|--------------------------------------------------------------------------
+1 -1
View File
@@ -13,7 +13,7 @@ return [
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
'default' => env('QUEUE_CONNECTION', 'redis'),
/*
|--------------------------------------------------------------------------
+3 -37
View File
@@ -52,47 +52,13 @@ Route::get('/', function () {
return redirect()->route('dashboard');
})->middleware(['auth']);
use App\Http\Controllers\DashboardController;
// Grupo de rutas protegidas por autenticación
Route::middleware(['auth'])->group(function () {
// Home: vista ligera (proyectos, tareas e incidencias del usuario + notificaciones)
Route::get('/dashboard', function () {
$user = Auth::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 view('dashboard', compact(
'user', 'projects', 'projectsCount', 'myTasks', 'myTasksFromTasks', 'myTasksCount',
'myIssues', 'myIssuesCount', 'notifications', 'unreadCount'
));
})->name('dashboard');
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
Route::prefix('reports')->name('reports.')->group(function () {