From a8a48f3f6f69f76ef628e54fb643c58a1fa8ed4b Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Fri, 3 Apr 2026 20:41:35 +0300 Subject: [PATCH] phase 7 --- .../Controllers/Admin/DashboardController.php | 77 +++++ app/Http/Controllers/Web/JobController.php | 12 + app/Models/AnalyticsEvent.php | 70 +++++ app/Models/ApiLog.php | 56 ++++ app/Models/JobFetchLog.php | 63 ++++ app/Services/ApiClientService.php | 171 +++++++++++ app/Services/MetricsService.php | 274 ++++++++++++++++++ app/Traits/HandlesJobErrors.php | 114 ++++++++ ...026_04_03_173428_create_api_logs_table.php | 39 +++ ..._03_173428_create_job_fetch_logs_table.php | 39 +++ ...3_173429_create_analytics_events_table.php | 38 +++ resources/views/admin/dashboard.blade.php | 224 ++++++++++++++ routes/web.php | 8 + 13 files changed, 1185 insertions(+) create mode 100644 app/Http/Controllers/Admin/DashboardController.php create mode 100644 app/Models/AnalyticsEvent.php create mode 100644 app/Models/ApiLog.php create mode 100644 app/Models/JobFetchLog.php create mode 100644 app/Services/ApiClientService.php create mode 100644 app/Services/MetricsService.php create mode 100644 app/Traits/HandlesJobErrors.php create mode 100644 database/migrations/2026_04_03_173428_create_api_logs_table.php create mode 100644 database/migrations/2026_04_03_173428_create_job_fetch_logs_table.php create mode 100644 database/migrations/2026_04_03_173429_create_analytics_events_table.php create mode 100644 resources/views/admin/dashboard.blade.php diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php new file mode 100644 index 0000000..9da1cfa --- /dev/null +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -0,0 +1,77 @@ +input('days', 7); + + $metrics = [ + 'api' => $this->metricsService->getApiMetrics($days), + 'fetch' => $this->metricsService->getJobFetchMetrics($days), + 'analytics' => $this->metricsService->getAnalyticsMetrics($days), + 'health' => $this->metricsService->getSystemHealth(), + ]; + + return view('admin.dashboard', compact('metrics', 'days')); + } + + /** + * Display API logs + */ + public function apiLogs(Request $request) + { + $query = ApiLog::with('source') + ->orderBy('created_at', 'desc'); + + if ($request->has('source_id')) { + $query->where('job_source_id', $request->source_id); + } + + if ($request->has('status')) { + $status = $request->status === 'success'; + $query->where('success', $status); + } + + $logs = $query->paginate(50); + + return view('admin.api-logs', compact('logs')); + } + + /** + * Display job fetch logs + */ + public function fetchLogs(Request $request) + { + $query = JobFetchLog::with('source') + ->orderBy('created_at', 'desc'); + + if ($request->has('source_id')) { + $query->where('job_source_id', $request->source_id); + } + + if ($request->has('status')) { + $status = $request->status === 'success'; + $query->where('success', $status); + } + + $logs = $query->paginate(50); + + return view('admin.fetch-logs', compact('logs')); + } +} diff --git a/app/Http/Controllers/Web/JobController.php b/app/Http/Controllers/Web/JobController.php index 7caef6b..7e1fcc5 100644 --- a/app/Http/Controllers/Web/JobController.php +++ b/app/Http/Controllers/Web/JobController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; +use App\Models\AnalyticsEvent; use App\Models\Job; use App\Models\JobCategory; use App\Repositories\JobRepository; @@ -35,6 +36,11 @@ class JobController extends Controller 'sort', ]); + // Track search event if keywords are provided + if (!empty($filters['keywords'])) { + AnalyticsEvent::logEvent(AnalyticsEvent::EVENT_SEARCH, null, $filters); + } + $perPage = $request->input('per_page', 20); $jobs = $this->searchService->search($filters, $perPage); @@ -52,6 +58,9 @@ class JobController extends Controller $job = Job::with(['source', 'category', 'jobLocation']) ->findOrFail($id); + // Track job view event + AnalyticsEvent::logEvent(AnalyticsEvent::EVENT_JOB_VIEW, $job->id); + $similarJobs = $this->jobRepository->getSimilarJobs($job, 5); return view('jobs.show', compact('job', 'similarJobs')); @@ -64,6 +73,9 @@ class JobController extends Controller { $category = JobCategory::where('slug', $slug)->firstOrFail(); + // Track category view event + AnalyticsEvent::logEvent(AnalyticsEvent::EVENT_CATEGORY_VIEW, null, ['category' => $category->name]); + $filters = $request->only([ 'keywords', 'location', diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 0000000..638b196 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,70 @@ + 'array', + 'created_at' => 'datetime', + ]; + + // Event types + const EVENT_JOB_VIEW = 'job_view'; + const EVENT_JOB_APPLY_CLICK = 'job_apply_click'; + const EVENT_SEARCH = 'search'; + const EVENT_FILTER = 'filter'; + const EVENT_CATEGORY_VIEW = 'category_view'; + + public function job(): BelongsTo + { + return $this->belongsTo(Job::class); + } + + /** + * Scope to get events by type + */ + public function scopeOfType($query, string $type) + { + return $query->where('event_type', $type); + } + + /** + * Scope to get events within a date range + */ + public function scopeWithinDays($query, int $days) + { + return $query->where('created_at', '>=', now()->subDays($days)); + } + + /** + * Log an event + */ + public static function logEvent(string $eventType, ?int $jobId = null, array $eventData = []): void + { + self::create([ + 'event_type' => $eventType, + 'job_id' => $jobId, + 'session_id' => session()->getId(), + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'referer' => request()->header('referer'), + 'event_data' => $eventData, + ]); + } +} diff --git a/app/Models/ApiLog.php b/app/Models/ApiLog.php new file mode 100644 index 0000000..0e999f3 --- /dev/null +++ b/app/Models/ApiLog.php @@ -0,0 +1,56 @@ + 'boolean', + 'request_params' => 'array', + 'response_data' => 'array', + ]; + + public function source(): BelongsTo + { + return $this->belongsTo(JobSource::class, 'job_source_id'); + } + + /** + * Scope to get only successful logs + */ + public function scopeSuccessful($query) + { + return $query->where('success', true); + } + + /** + * Scope to get only failed logs + */ + public function scopeFailed($query) + { + return $query->where('success', false); + } + + /** + * Scope to get logs within a date range + */ + public function scopeWithinDays($query, int $days) + { + return $query->where('created_at', '>=', now()->subDays($days)); + } +} diff --git a/app/Models/JobFetchLog.php b/app/Models/JobFetchLog.php new file mode 100644 index 0000000..5f745a1 --- /dev/null +++ b/app/Models/JobFetchLog.php @@ -0,0 +1,63 @@ + 'boolean', + 'metadata' => 'array', + ]; + + public function source(): BelongsTo + { + return $this->belongsTo(JobSource::class, 'job_source_id'); + } + + /** + * Scope to get only successful logs + */ + public function scopeSuccessful($query) + { + return $query->where('success', true); + } + + /** + * Scope to get only failed logs + */ + public function scopeFailed($query) + { + return $query->where('success', false); + } + + /** + * Scope to get logs within a date range + */ + public function scopeWithinDays($query, int $days) + { + return $query->where('created_at', '>=', now()->subDays($days)); + } + + /** + * Get total jobs processed + */ + public function getTotalJobsProcessedAttribute(): int + { + return $this->jobs_created + $this->jobs_updated + $this->jobs_skipped; + } +} diff --git a/app/Services/ApiClientService.php b/app/Services/ApiClientService.php new file mode 100644 index 0000000..00197f1 --- /dev/null +++ b/app/Services/ApiClientService.php @@ -0,0 +1,171 @@ +executeRequest($method, $endpoint, $params); + $responseTime = (int)((microtime(true) - $startTime) * 1000); + + // Log successful request + $this->logApiRequest($source, $endpoint, $method, $params, $response, $responseTime, true); + + return [ + 'success' => true, + 'data' => $response->json(), + 'status_code' => $response->status(), + 'response_time' => $responseTime, + ]; + } catch (\Exception $e) { + $lastException = $e; + + // Log failed attempt + Log::warning("API request failed (attempt {$attempt}/{$maxRetries})", [ + 'source' => $source->name, + 'endpoint' => $endpoint, + 'error' => $e->getMessage(), + ]); + + if ($attempt < $maxRetries) { + // Exponential backoff: wait 2^attempt seconds + sleep(pow(2, $attempt)); + } + } + } + + $responseTime = (int)((microtime(true) - $startTime) * 1000); + + // Log final failure + $this->logApiRequest( + $source, + $endpoint, + $method, + $params, + null, + $responseTime, + false, + $lastException->getMessage() ?? 'Unknown error' + ); + + return [ + 'success' => false, + 'error' => $lastException->getMessage() ?? 'Unknown error', + 'response_time' => $responseTime, + ]; + } + + /** + * Execute the HTTP request + */ + private function executeRequest(string $method, string $endpoint, array $params): \Illuminate\Http\Client\Response + { + $http = Http::timeout(30) + ->retry(1, 100) + ->withHeaders([ + 'User-Agent' => 'JobifyGarden/1.0', + 'Accept' => 'application/json', + ]); + + return match (strtoupper($method)) { + 'GET' => $http->get($endpoint, $params), + 'POST' => $http->post($endpoint, $params), + 'PUT' => $http->put($endpoint, $params), + 'DELETE' => $http->delete($endpoint, $params), + default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"), + }; + } + + /** + * Log API request to database + */ + private function logApiRequest( + JobSource $source, + string $endpoint, + string $method, + array $params, + $response, + int $responseTime, + bool $success, + ?string $errorMessage = null + ): void { + try { + ApiLog::create([ + 'job_source_id' => $source->id, + 'endpoint' => $endpoint, + 'method' => $method, + 'status_code' => $response?->status(), + 'response_time_ms' => $responseTime, + 'success' => $success, + 'error_message' => $errorMessage, + 'request_params' => !empty($params) ? $params : null, + 'response_data' => $success && $response ? [ + 'status' => $response->status(), + 'size' => strlen($response->body()), + ] : null, + ]); + } catch (\Exception $e) { + Log::error('Failed to log API request', [ + 'error' => $e->getMessage(), + 'endpoint' => $endpoint, + ]); + } + } + + /** + * Check if source is rate limited + */ + public function isRateLimited(JobSource $source): bool + { + if (!$source->rate_limit) { + return false; + } + + // Check requests in the last hour + $recentRequests = ApiLog::where('job_source_id', $source->id) + ->where('created_at', '>=', now()->subHour()) + ->count(); + + return $recentRequests >= $source->rate_limit; + } + + /** + * Get rate limit info for a source + */ + public function getRateLimitInfo(JobSource $source): array + { + $recentRequests = ApiLog::where('job_source_id', $source->id) + ->where('created_at', '>=', now()->subHour()) + ->count(); + + return [ + 'limit' => $source->rate_limit, + 'used' => $recentRequests, + 'remaining' => max(0, $source->rate_limit - $recentRequests), + 'reset_at' => now()->addHour()->startOfHour(), + ]; + } +} diff --git a/app/Services/MetricsService.php b/app/Services/MetricsService.php new file mode 100644 index 0000000..5e912db --- /dev/null +++ b/app/Services/MetricsService.php @@ -0,0 +1,274 @@ +subDays($days); + + return [ + 'total_requests' => ApiLog::where('created_at', '>=', $startDate)->count(), + 'successful_requests' => ApiLog::successful()->where('created_at', '>=', $startDate)->count(), + 'failed_requests' => ApiLog::failed()->where('created_at', '>=', $startDate)->count(), + 'average_response_time' => ApiLog::where('created_at', '>=', $startDate) + ->whereNotNull('response_time_ms') + ->avg('response_time_ms'), + 'success_rate' => $this->calculateSuccessRate($days), + 'by_source' => $this->getApiMetricsBySource($days), + ]; + } + + /** + * Get job fetch metrics + */ + public function getJobFetchMetrics(int $days = 7): array + { + $startDate = now()->subDays($days); + + return [ + 'total_fetches' => JobFetchLog::where('created_at', '>=', $startDate)->count(), + 'successful_fetches' => JobFetchLog::successful()->where('created_at', '>=', $startDate)->count(), + 'failed_fetches' => JobFetchLog::failed()->where('created_at', '>=', $startDate)->count(), + 'total_jobs_fetched' => JobFetchLog::where('created_at', '>=', $startDate)->sum('jobs_fetched'), + 'total_jobs_created' => JobFetchLog::where('created_at', '>=', $startDate)->sum('jobs_created'), + 'total_jobs_updated' => JobFetchLog::where('created_at', '>=', $startDate)->sum('jobs_updated'), + 'total_jobs_skipped' => JobFetchLog::where('created_at', '>=', $startDate)->sum('jobs_skipped'), + 'average_duration' => JobFetchLog::where('created_at', '>=', $startDate) + ->whereNotNull('duration_seconds') + ->avg('duration_seconds'), + 'by_source' => $this->getJobFetchMetricsBySource($days), + ]; + } + + /** + * Get analytics metrics + */ + public function getAnalyticsMetrics(int $days = 7): array + { + $startDate = now()->subDays($days); + + return [ + 'total_events' => AnalyticsEvent::where('created_at', '>=', $startDate)->count(), + 'job_views' => AnalyticsEvent::ofType(AnalyticsEvent::EVENT_JOB_VIEW) + ->where('created_at', '>=', $startDate)->count(), + 'job_apply_clicks' => AnalyticsEvent::ofType(AnalyticsEvent::EVENT_JOB_APPLY_CLICK) + ->where('created_at', '>=', $startDate)->count(), + 'searches' => AnalyticsEvent::ofType(AnalyticsEvent::EVENT_SEARCH) + ->where('created_at', '>=', $startDate)->count(), + 'unique_sessions' => AnalyticsEvent::where('created_at', '>=', $startDate) + ->distinct('session_id')->count('session_id'), + 'click_through_rate' => $this->calculateClickThroughRate($days), + 'popular_jobs' => $this->getPopularJobs($days), + 'popular_searches' => $this->getPopularSearches($days), + ]; + } + + /** + * Get system health metrics + */ + public function getSystemHealth(): array + { + $recentFailures = ApiLog::failed() + ->where('created_at', '>=', now()->subHours(1)) + ->count(); + + $recentFetchFailures = JobFetchLog::failed() + ->where('created_at', '>=', now()->subHours(24)) + ->count(); + + return [ + 'status' => $this->determineSystemStatus($recentFailures, $recentFetchFailures), + 'api_failures_last_hour' => $recentFailures, + 'fetch_failures_last_24h' => $recentFetchFailures, + 'last_successful_fetch' => JobFetchLog::successful() + ->latest() + ->first()?->created_at, + 'sources_status' => $this->getSourcesStatus(), + ]; + } + + /** + * Calculate API success rate + */ + private function calculateSuccessRate(int $days): float + { + $total = ApiLog::withinDays($days)->count(); + if ($total === 0) { + return 0; + } + + $successful = ApiLog::successful()->withinDays($days)->count(); + return round(($successful / $total) * 100, 2); + } + + /** + * Get API metrics by source + */ + private function getApiMetricsBySource(int $days): array + { + return ApiLog::select('job_source_id') + ->selectRaw('COUNT(*) as total') + ->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful') + ->selectRaw('AVG(response_time_ms) as avg_response_time') + ->withinDays($days) + ->with('source:id,name') + ->groupBy('job_source_id') + ->get() + ->map(function ($item) { + return [ + 'source' => $item->source->name ?? 'Unknown', + 'total_requests' => $item->total, + 'successful' => $item->successful, + 'success_rate' => $item->total > 0 ? round(($item->successful / $item->total) * 100, 2) : 0, + 'avg_response_time' => round($item->avg_response_time, 2), + ]; + }) + ->toArray(); + } + + /** + * Get job fetch metrics by source + */ + private function getJobFetchMetricsBySource(int $days): array + { + return JobFetchLog::select('job_source_id') + ->selectRaw('COUNT(*) as total_fetches') + ->selectRaw('SUM(jobs_fetched) as jobs_fetched') + ->selectRaw('SUM(jobs_created) as jobs_created') + ->selectRaw('SUM(jobs_updated) as jobs_updated') + ->selectRaw('AVG(duration_seconds) as avg_duration') + ->withinDays($days) + ->with('source:id,name') + ->groupBy('job_source_id') + ->get() + ->map(function ($item) { + return [ + 'source' => $item->source->name ?? 'Unknown', + 'total_fetches' => $item->total_fetches, + 'jobs_fetched' => $item->jobs_fetched, + 'jobs_created' => $item->jobs_created, + 'jobs_updated' => $item->jobs_updated, + 'avg_duration' => round($item->avg_duration, 2), + ]; + }) + ->toArray(); + } + + /** + * Calculate click-through rate + */ + private function calculateClickThroughRate(int $days): float + { + $views = AnalyticsEvent::ofType(AnalyticsEvent::EVENT_JOB_VIEW) + ->withinDays($days) + ->count(); + + if ($views === 0) { + return 0; + } + + $clicks = AnalyticsEvent::ofType(AnalyticsEvent::EVENT_JOB_APPLY_CLICK) + ->withinDays($days) + ->count(); + + return round(($clicks / $views) * 100, 2); + } + + /** + * Get popular jobs + */ + private function getPopularJobs(int $days, int $limit = 10): array + { + return AnalyticsEvent::select('job_id') + ->selectRaw('COUNT(*) as view_count') + ->selectRaw('SUM(CASE WHEN event_type = ? THEN 1 ELSE 0 END) as apply_clicks', [AnalyticsEvent::EVENT_JOB_APPLY_CLICK]) + ->whereNotNull('job_id') + ->withinDays($days) + ->with('job:id,title,company') + ->groupBy('job_id') + ->orderByDesc('view_count') + ->limit($limit) + ->get() + ->map(function ($item) { + return [ + 'job_id' => $item->job_id, + 'title' => $item->job->title ?? 'Unknown', + 'company' => $item->job->company ?? 'Unknown', + 'views' => $item->view_count, + 'apply_clicks' => $item->apply_clicks, + ]; + }) + ->toArray(); + } + + /** + * Get popular search terms + */ + private function getPopularSearches(int $days, int $limit = 10): array + { + return AnalyticsEvent::select(DB::raw('JSON_EXTRACT(event_data, "$.keywords") as search_term')) + ->selectRaw('COUNT(*) as search_count') + ->ofType(AnalyticsEvent::EVENT_SEARCH) + ->withinDays($days) + ->whereNotNull(DB::raw('JSON_EXTRACT(event_data, "$.keywords")')) + ->groupBy('search_term') + ->orderByDesc('search_count') + ->limit($limit) + ->get() + ->map(function ($item) { + return [ + 'term' => trim($item->search_term, '"'), + 'count' => $item->search_count, + ]; + }) + ->toArray(); + } + + /** + * Determine system status + */ + private function determineSystemStatus(int $apiFailures, int $fetchFailures): string + { + if ($apiFailures > 10 || $fetchFailures > 5) { + return 'critical'; + } + + if ($apiFailures > 5 || $fetchFailures > 2) { + return 'warning'; + } + + return 'healthy'; + } + + /** + * Get status of all sources + */ + private function getSourcesStatus(): array + { + return JobSource::get()->map(function ($source) { + $lastFetch = JobFetchLog::where('job_source_id', $source->id) + ->latest() + ->first(); + + return [ + 'id' => $source->id, + 'name' => $source->name, + 'is_active' => $source->is_active, + 'last_fetch_success' => $lastFetch?->success ?? null, + 'last_fetch_at' => $lastFetch?->created_at ?? null, + 'jobs_in_last_fetch' => $lastFetch?->jobs_created ?? 0, + ]; + })->toArray(); + } +} diff --git a/app/Traits/HandlesJobErrors.php b/app/Traits/HandlesJobErrors.php new file mode 100644 index 0000000..0c5af74 --- /dev/null +++ b/app/Traits/HandlesJobErrors.php @@ -0,0 +1,114 @@ + $sourceId, + 'error' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + 'metadata' => $metadata, + ]); + + // Record in database + JobFetchLog::create([ + 'job_source_id' => $sourceId, + 'success' => false, + 'jobs_fetched' => 0, + 'jobs_created' => 0, + 'jobs_updated' => 0, + 'jobs_skipped' => 0, + 'error_message' => $exception->getMessage(), + 'metadata' => array_merge($metadata, [ + 'exception_class' => get_class($exception), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]), + ]); + + // Check if we need to disable the source due to repeated failures + $this->checkAndDisableSource($sourceId); + } + + /** + * Check for repeated failures and disable source if necessary + */ + protected function checkAndDisableSource(int $sourceId): void + { + $recentFailures = JobFetchLog::where('job_source_id', $sourceId) + ->where('success', false) + ->where('created_at', '>=', now()->subHours(24)) + ->count(); + + // Disable source after 5 consecutive failures in 24 hours + if ($recentFailures >= 5) { + $source = \App\Models\JobSource::find($sourceId); + if ($source && $source->is_active) { + $source->update(['is_active' => false]); + + Log::warning("Source {$source->name} has been disabled due to repeated failures", [ + 'source_id' => $sourceId, + 'failures' => $recentFailures, + ]); + + // You could send a notification to admins here + $this->notifyAdmins($source, $recentFailures); + } + } + } + + /** + * Log successful job fetch + */ + protected function logSuccessfulFetch( + int $sourceId, + int $jobsFetched, + int $jobsCreated, + int $jobsUpdated, + int $jobsSkipped, + int $durationSeconds, + array $metadata = [] + ): void { + JobFetchLog::create([ + 'job_source_id' => $sourceId, + 'success' => true, + 'jobs_fetched' => $jobsFetched, + 'jobs_created' => $jobsCreated, + 'jobs_updated' => $jobsUpdated, + 'jobs_skipped' => $jobsSkipped, + 'duration_seconds' => $durationSeconds, + 'metadata' => $metadata, + ]); + + Log::info('Job fetch completed successfully', [ + 'source_id' => $sourceId, + 'jobs_fetched' => $jobsFetched, + 'jobs_created' => $jobsCreated, + 'jobs_updated' => $jobsUpdated, + 'duration' => $durationSeconds, + ]); + } + + /** + * Notify admins about source failure (placeholder) + */ + protected function notifyAdmins($source, int $failureCount): void + { + // TODO: Implement notification logic + // Could use Laravel notifications, email, Slack, etc. + Log::critical("ADMIN ALERT: Source {$source->name} disabled after {$failureCount} failures"); + } +} diff --git a/database/migrations/2026_04_03_173428_create_api_logs_table.php b/database/migrations/2026_04_03_173428_create_api_logs_table.php new file mode 100644 index 0000000..22f824a --- /dev/null +++ b/database/migrations/2026_04_03_173428_create_api_logs_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('job_source_id')->nullable()->constrained()->onDelete('cascade'); + $table->string('endpoint'); + $table->string('method', 10); + $table->integer('status_code')->nullable(); + $table->integer('response_time_ms')->nullable(); + $table->boolean('success')->default(false); + $table->text('error_message')->nullable(); + $table->json('request_params')->nullable(); + $table->json('response_data')->nullable(); + $table->timestamps(); + + $table->index(['job_source_id', 'created_at']); + $table->index(['success', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('api_logs'); + } +}; diff --git a/database/migrations/2026_04_03_173428_create_job_fetch_logs_table.php b/database/migrations/2026_04_03_173428_create_job_fetch_logs_table.php new file mode 100644 index 0000000..fa5eeb0 --- /dev/null +++ b/database/migrations/2026_04_03_173428_create_job_fetch_logs_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('job_source_id')->constrained()->onDelete('cascade'); + $table->boolean('success')->default(false); + $table->integer('jobs_fetched')->default(0); + $table->integer('jobs_created')->default(0); + $table->integer('jobs_updated')->default(0); + $table->integer('jobs_skipped')->default(0); + $table->integer('duration_seconds')->nullable(); + $table->text('error_message')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['job_source_id', 'created_at']); + $table->index(['success', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_fetch_logs'); + } +}; diff --git a/database/migrations/2026_04_03_173429_create_analytics_events_table.php b/database/migrations/2026_04_03_173429_create_analytics_events_table.php new file mode 100644 index 0000000..1c7693c --- /dev/null +++ b/database/migrations/2026_04_03_173429_create_analytics_events_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('event_type', 50); // job_view, job_apply_click, search, etc. + $table->foreignId('job_id')->nullable()->constrained()->onDelete('cascade'); + $table->string('session_id', 100)->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent')->nullable(); + $table->string('referer')->nullable(); + $table->json('event_data')->nullable(); // Additional event-specific data + $table->timestamp('created_at'); + + $table->index(['event_type', 'created_at']); + $table->index(['job_id', 'event_type', 'created_at']); + $table->index('session_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php new file mode 100644 index 0000000..28a95a6 --- /dev/null +++ b/resources/views/admin/dashboard.blade.php @@ -0,0 +1,224 @@ +@extends('layouts.app') + +@section('title', 'Admin Dashboard - JobifyGarden') + +@section('content') +
+ +
+

Admin Dashboard

+ +
+ + +
+ + +
+ + +
+

System Health

+
+
+
+ @if($metrics['health']['status'] === 'healthy') + ✓ + @elseif($metrics['health']['status'] === 'warning') + ⚠ + @else + ✗ + @endif +
+
+
+ {{ $metrics['health']['status'] }} +
+
+ API Failures (1h): {{ $metrics['health']['api_failures_last_hour'] }} | + Fetch Failures (24h): {{ $metrics['health']['fetch_failures_last_24h'] }} +
+ @if($metrics['health']['last_successful_fetch']) +
+ Last successful fetch: {{ $metrics['health']['last_successful_fetch']->diffForHumans() }} +
+ @endif +
+
+
+
+ + +
+

API Performance

+
+
+
Total Requests
+
{{ number_format($metrics['api']['total_requests']) }}
+
+
+
Success Rate
+
{{ $metrics['api']['success_rate'] }}%
+
+
+
Failed Requests
+
{{ number_format($metrics['api']['failed_requests']) }}
+
+
+
Avg Response Time
+
{{ round($metrics['api']['average_response_time']) }}ms
+
+
+
+ + +
+

Job Fetching

+
+
+
Total Fetches
+
{{ number_format($metrics['fetch']['total_fetches']) }}
+
+
+
Jobs Created
+
{{ number_format($metrics['fetch']['total_jobs_created']) }}
+
+
+
Jobs Updated
+
{{ number_format($metrics['fetch']['total_jobs_updated']) }}
+
+
+
Avg Duration
+
{{ round($metrics['fetch']['average_duration']) }}s
+
+
+ + @if(!empty($metrics['fetch']['by_source'])) +
+

By Source

+
+ + + + + + + + + + + + @foreach($metrics['fetch']['by_source'] as $source) + + + + + + + + @endforeach + +
SourceFetchesCreatedUpdatedAvg Duration
{{ $source['source'] }}{{ number_format($source['total_fetches']) }}{{ number_format($source['jobs_created']) }}{{ number_format($source['jobs_updated']) }}{{ $source['avg_duration'] }}s
+
+
+ @endif +
+ + +
+

User Analytics

+
+
+
Job Views
+
{{ number_format($metrics['analytics']['job_views']) }}
+
+
+
Apply Clicks
+
{{ number_format($metrics['analytics']['job_apply_clicks']) }}
+
+
+
CTR
+
{{ $metrics['analytics']['click_through_rate'] }}%
+
+
+
Unique Sessions
+
{{ number_format($metrics['analytics']['unique_sessions']) }}
+
+
+ +
+ + @if(!empty($metrics['analytics']['popular_jobs'])) +
+

Most Viewed Jobs

+
+ @foreach($metrics['analytics']['popular_jobs'] as $job) +
+
+
{{ $job['title'] }}
+
{{ $job['company'] }}
+
+
+
{{ $job['views'] }} views
+
{{ $job['apply_clicks'] }} applies
+
+
+ @endforeach +
+
+ @endif + + + @if(!empty($metrics['analytics']['popular_searches'])) +
+

Popular Searches

+
+ @foreach($metrics['analytics']['popular_searches'] as $search) +
+
{{ $search['term'] }}
+
{{ $search['count'] }} searches
+
+ @endforeach +
+
+ @endif +
+
+ + +
+

Job Sources Status

+
+
+ @foreach($metrics['health']['sources_status'] as $source) +
+
+

{{ $source['name'] }}

+ + {{ $source['is_active'] ? 'Active' : 'Inactive' }} + +
+ @if($source['last_fetch_at']) +
+ Last fetch: {{ \Carbon\Carbon::parse($source['last_fetch_at'])->diffForHumans() }} +
+
+ {{ $source['last_fetch_success'] ? 'Success' : 'Failed' }} | {{ $source['jobs_in_last_fetch'] }} jobs +
+ @else +
No fetches yet
+ @endif +
+ @endforeach +
+
+
+
+@endsection diff --git a/routes/web.php b/routes/web.php index 401cf4d..e686384 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ group(function () { Route::get('/category/{slug}', [JobController::class, 'category'])->name('jobs.category'); Route::get('/{id}', [JobController::class, 'show'])->name('jobs.show'); }); + +// Admin routes (in production, protect these with authentication middleware) +Route::prefix('admin')->name('admin.')->group(function () { + Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); + Route::get('/logs/api', [DashboardController::class, 'apiLogs'])->name('logs.api'); + Route::get('/logs/fetch', [DashboardController::class, 'fetchLogs'])->name('logs.fetch'); +});