q('q', '')); $companyId = (int)$this->q('company_id', 0); $citiesRaw = trim((string)$this->q('cities', '')); $remoteOnly = (int)$this->q('remote_only', 0); $showArch = (int)$this->q('show_archived', 0); // 0 = hide by default $user = \auth_user(); $userId = $user ? (int)$user['user_id'] : 0; $page = max(1, (int)$this->q('page', 1)); $per = 25; $where = ['1=1']; $params = []; if ($q !== '') { $where[] = '(j.title LIKE :q OR j.location LIKE :q)'; $params[':q'] = "%{$q}%"; } if ($companyId > 0) { $where[] = 'j.company_id = :cid'; $params[':cid'] = $companyId; } // Cities filter $cityTerms = array_filter(array_map('trim', explode(',', $citiesRaw))); if (!empty($cityTerms)) { $chunks = []; foreach ($cityTerms as $i => $term) { $key = ":city{$i}"; $chunks[] = "j.location LIKE {$key}"; $params[$key] = "%{$term}%"; } $where[] = '(' . implode(' OR ', $chunks) . ')'; } if ($remoteOnly === 1) { $where[] = "(LOWER(j.location) LIKE '%remote%')"; } // User-specific status join/filter // User-specific status join/filter $joinUjs = ''; $selectStatus = 'NULL AS user_status'; if ($userId > 0) { $joinUjs = 'LEFT JOIN user_job_status ujs ON ujs.job_id = j.id AND ujs.user_id = :uid'; $params[':uid'] = $userId; $selectStatus = 'ujs.status AS user_status'; if ($showArch === 0) { $where[] = '(ujs.status IS NULL OR ujs.status NOT IN ("archived","hidden"))'; } } $whereSql = implode(' AND ', $where); // Main query — concatenate dynamic bits OUTSIDE the quoted string $sql = 'SELECT DATE_FORMAT(j.posted_at, "%Y-%m-%d") AS date_posted, DATE_FORMAT(j.first_seen, "%Y-%m-%d %H:%i") AS date_scraped, c.name AS company_name, c.site AS company_site, j.title, j.req_id AS company_req_id, j.location, (CASE WHEN LOWER(j.location) LIKE "%remote%" THEN 1 ELSE 0 END) AS is_remote, j.pay, j.url, j.id AS job_id, j.company_id, ' . $selectStatus . ' FROM jobs j LEFT JOIN companies c ON c.id = j.company_id ' . $joinUjs . ' WHERE ' . $whereSql . ' ORDER BY j.posted_at DESC, j.id DESC'; // Count query (for pagination) $countSql = 'SELECT COUNT(*) FROM jobs j LEFT JOIN companies c ON c.id = j.company_id ' . $joinUjs . ' WHERE ' . $whereSql; $pageData = Db::paginate($sql, $params, $page, $per, $countSql); $companies = Db::fetchAll('SELECT id, name FROM companies ORDER BY name'); return \render('jobs/index', [ 'q' => $q, 'companyId' => $companyId, 'cities' => $citiesRaw, 'remoteOnly' => $remoteOnly, 'showArchived' => $showArch, 'companies' => $companies, 'rows' => $pageData['rows'], 'total' => $pageData['total'], 'page' => $pageData['page'], 'perPage' => $pageData['perPage'], 'title' => 'Jobs', ]); } public function show(int $id): string { $job = Db::fetchOne( 'SELECT j.*, c.name AS company_name, c.site AS company_site FROM jobs j LEFT JOIN companies c ON c.id = j.company_id WHERE j.id = :id', [':id' => $id] ); if (!$job) { http_response_code(404); return 'Job not found'; } $actions = Db::fetchAll( 'SELECT a.*, u.email FROM job_actions a LEFT JOIN users u ON u.id = a.user_id WHERE a.job_id = :id ORDER BY a.acted_at DESC, a.id DESC', [':id' => $id] ); return \render('jobs/show', [ 'job' => $job, 'actions' => $actions, 'title' => 'Job Detail', ]); } // --- archive (aka "Deleted" in UI) --- public function archive(int $id): void { \auth_require(); $user = \auth_user(); if (!\csrf_check((string)($_POST['csrf'] ?? ''))) { http_response_code(400); exit('Bad CSRF'); } $uid = (int)$user['user_id']; Db::exec( 'INSERT INTO job_actions (user_id, job_id, action, action_note) VALUES (:u, :j, "archived", NULL)', [':u' => $uid, ':j' => $id] ); Db::exec( 'INSERT INTO user_job_status (user_id, job_id, status) VALUES (:u, :j, "archived") ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at = CURRENT_TIMESTAMP', [':u' => $uid, ':j' => $id] ); header('Location: ' . \url('/jobs') . (empty($_GET) ? '' : '?' . http_build_query($_GET))); exit; } public function bulkArchive(): void { \auth_require(); if (!\csrf_check((string)($_POST['_token'] ?? $_POST['csrf'] ?? $_POST['csrf_token'] ?? ''))) { http_response_code(400); exit('Bad CSRF'); } $uid = $this->currentUserId(); $ids = $_POST['ids'] ?? []; if (is_string($ids)) { $ids = [$ids]; } $ids = array_values(array_unique(array_map('intval', (array)$ids))); $ids = array_filter($ids, fn ($v) => $v > 0); if (!$ids) { header('Content-Type: application/json'); echo json_encode(['ok' => true, 'archived' => []]); return; } $pdo = Db::pdo(); $pdo->beginTransaction(); try { $stmt = $pdo->prepare( 'INSERT INTO user_job_status (user_id, job_id, status) VALUES (:u, :j, "archived") ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at = CURRENT_TIMESTAMP' ); foreach ($ids as $jid) { $stmt->execute([':u' => $uid, ':j' => $jid]); } $pdo->commit(); header('Content-Type: application/json'); echo json_encode(['ok' => true, 'archived' => $ids]); return; } catch (\Throwable $e) { $pdo->rollBack(); http_response_code(500); header('Content-Type: application/json'); echo json_encode(['ok' => false, 'error' => 'Bulk archive failed']); return; } } // --- mark applied (toggle) --- public function markApplied(int $jobId): void { \auth_require(); if (!\csrf_check((string)($_POST['csrf'] ?? $_POST['_token'] ?? $_POST['csrf_token'] ?? ''))) { http_response_code(400); exit('Bad CSRF'); } $uid = $this->currentUserId(); $pdo = Db::pdo(); $cur = $pdo->prepare('SELECT status FROM user_job_status WHERE user_id = ? AND job_id = ?'); $cur->execute([$uid, $jobId]); $row = $cur->fetch(PDO::FETCH_ASSOC); if ($row && ($row['status'] ?? null) === 'applied') { // Toggle OFF $pdo->prepare('DELETE FROM user_job_status WHERE user_id = ? AND job_id = ? AND status = "applied"') ->execute([$uid, $jobId]); $isApplied = 0; } else { // Toggle ON $sql = 'INSERT INTO user_job_status (user_id, job_id, status) VALUES (:u, :j, "applied") ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at = CURRENT_TIMESTAMP'; $pdo->prepare($sql)->execute([':u' => $uid, ':j' => $jobId]); $isApplied = 1; } // AJAX-friendly JSON if called via fetch/XHR $xhr = strtolower($_SERVER['HTTP_X_REQUESTED_WITH'] ?? ''); if ($xhr === 'fetch' || $xhr === 'xmlhttprequest') { header('Content-Type: application/json'); echo json_encode(['ok' => true, 'is_applied' => $isApplied]); return; } // Regular POST fallback header('Location: ' . \url('/jobs') . (empty($_GET) ? '' : '?' . http_build_query($_GET))); exit; }}