diff --git a/.env.example b/.env.example index c0660ea..2457c8c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.hermes/plans/2026-08-28_140000-construprogress-improvements.md b/.hermes/plans/2026-08-28_140000-construprogress-improvements.md new file mode 100644 index 0000000..0e9a42d --- /dev/null +++ b/.hermes/plans/2026-08-28_140000-construprogress-improvements.md @@ -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 + + +``` + +**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) \ No newline at end of file diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..dfe1379 --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,57 @@ +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); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php index c03b03c..5201099 100644 --- a/app/Http/Controllers/ReportController.php +++ b/app/Http/Controllers/ReportController.php @@ -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); } diff --git a/app/Livewire/Projects/ProjectMap.php b/app/Livewire/Projects/ProjectMap.php index 943d6f3..27ede18 100644 --- a/app/Livewire/Projects/ProjectMap.php +++ b/app/Livewire/Projects/ProjectMap.php @@ -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']) diff --git a/app/Livewire/Tasks/TaskManager.php b/app/Livewire/Tasks/TaskManager.php index a10b430..fe58a94 100644 --- a/app/Livewire/Tasks/TaskManager.php +++ b/app/Livewire/Tasks/TaskManager.php @@ -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) { diff --git a/app/Observers/FeatureObserver.php b/app/Observers/FeatureObserver.php new file mode 100644 index 0000000..3058042 --- /dev/null +++ b/app/Observers/FeatureObserver.php @@ -0,0 +1,26 @@ +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(); + } + } +} \ No newline at end of file diff --git a/app/Observers/InspectionObserver.php b/app/Observers/InspectionObserver.php new file mode 100644 index 0000000..581327f --- /dev/null +++ b/app/Observers/InspectionObserver.php @@ -0,0 +1,26 @@ +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(); + } + } +} \ No newline at end of file diff --git a/app/Observers/IssueObserver.php b/app/Observers/IssueObserver.php new file mode 100644 index 0000000..bf9634f --- /dev/null +++ b/app/Observers/IssueObserver.php @@ -0,0 +1,26 @@ +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(); + } + } +} \ No newline at end of file diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2bfb865..cb17606 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); diff --git a/composer.json b/composer.json index 21520ff..9cdc160 100644 --- a/composer.json +++ b/composer.json @@ -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" }, diff --git a/composer.lock b/composer.lock index f8de404..8177505 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/composer.phar b/composer.phar new file mode 100644 index 0000000..2c414ec Binary files /dev/null and b/composer.phar differ diff --git a/config/cache.php b/config/cache.php index b32aead..b375022 100644 --- a/config/cache.php +++ b/config/cache.php @@ -15,7 +15,7 @@ return [ | */ - 'default' => env('CACHE_STORE', 'database'), + 'default' => env('CACHE_STORE', 'redis'), /* |-------------------------------------------------------------------------- diff --git a/config/queue.php b/config/queue.php index 79c2c0a..a0f669c 100644 --- a/config/queue.php +++ b/config/queue.php @@ -13,7 +13,7 @@ return [ | */ - 'default' => env('QUEUE_CONNECTION', 'database'), + 'default' => env('QUEUE_CONNECTION', 'redis'), /* |-------------------------------------------------------------------------- diff --git a/routes/web.php b/routes/web.php index d34ec37..6e1b606 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 () {