diff --git a/TODO.md b/TODO.md index 3866314..de27e71 100644 --- a/TODO.md +++ b/TODO.md @@ -39,7 +39,7 @@ third-party dependencies. The tasks below are ordered from critical to nice-to-h `src/Database.php` and `src/AdminAuth.php` via `APP_ROOT` (the constant already defined in `bootstrap.php`), removing the fragile relative-path `../../` chains. -- [ ] **Eliminate the duplicate `searchTheses` / `countSearchResults` condition block** +- [x] **Eliminate the duplicate `searchTheses` / `countSearchResults` condition block** `Database::searchTheses()` and `Database::countSearchResults()` share identical WHERE-clause construction logic (~80 lines each). Extract a private `buildSearchConditions(array $params): array` helper that returns `[$conditions, @@ -52,7 +52,7 @@ third-party dependencies. The tasks below are ordered from critical to nice-to-h instead promoting the most-used raw queries into `Database` methods, reducing direct PDO exposure. -- [ ] **Move inline SQL in `admin/index.php` into `Database`** +- [x] **Move inline SQL in `admin/index.php` into `Database`** `admin/index.php` builds a raw SQL query with dynamic filter conditions directly in the page. This is the only admin page doing so. Add a `getThesesList(array $filters): array` method to `Database` to match the pattern used everywhere else. @@ -67,20 +67,20 @@ third-party dependencies. The tasks below are ordered from critical to nice-to-h ## What Can Be Removed / Simplified -- [ ] **Remove `include_template()` helper from `bootstrap.php` — it is never called** +- [x] **Remove `include_template()` helper from `bootstrap.php` — it is never called** The function `include_template($name)` in `config/bootstrap.php` is dead code; pages use direct `include APP_ROOT . '/templates/...'` instead. -- [ ] **Remove the Composer autoload stub from `bootstrap.php`** +- [x] **Remove the Composer autoload stub from `bootstrap.php`** `bootstrap.php` has `if (file_exists(APP_ROOT . '/vendor/autoload.php'))` — there is no Composer vendor directory and no plan for one. Remove this dead branch. -- [ ] **Delete `apps/admin/` directory** +- [x] **Delete `apps/admin/` directory** `apps/admin/` contains only `data/` (empty with test data) and `error.log` and `test.db`. It appears to be a leftover from an earlier structure. If confirmed unused, delete it. -- [ ] **Remove `apps/` directory entirely if it contains only residual artefacts** +- [x] **Remove `apps/` directory entirely if it contains only residual artefacts** Related to the above — verify no active code references `apps/`. --- @@ -102,7 +102,7 @@ third-party dependencies. The tasks below are ordered from critical to nice-to-h ## Testing Infrastructure -- [ ] **Fix `SearchTest.php` — it calls `searchTheses()` with a string, not an array** +- [x] **Fix `SearchTest.php` — it calls `searchTheses()` with a string, not an array** `$db->searchTheses('art')` passes a string, but `searchTheses()` expects `array $params`. This test would throw a TypeError at runtime. Fix the call to `$db->searchTheses(['query' => 'art'])`. diff --git a/apps/admin/data/covers/Théophile Gervreau-Mercier_2024_.png b/apps/admin/data/covers/Théophile Gervreau-Mercier_2024_.png deleted file mode 100644 index 0a109c4..0000000 Binary files a/apps/admin/data/covers/Théophile Gervreau-Mercier_2024_.png and /dev/null differ diff --git a/apps/admin/error.log b/apps/admin/error.log deleted file mode 100644 index ee18d34..0000000 --- a/apps/admin/error.log +++ /dev/null @@ -1,251 +0,0 @@ -[27-Jan-2026 14:57:08 UTC] FILES array: Array -( - [couverture] => Array - ( - [name] => - [full_path] => - [type] => - [tmp_name] => - [error] => 4 - [size] => 0 - ) - - [files] => Array - ( - [name] => Array - ( - [0] => - ) - - [full_path] => Array - ( - [0] => - ) - - [type] => Array - ( - [0] => - ) - - [tmp_name] => Array - ( - [0] => - ) - - [error] => Array - ( - [0] => 4 - ) - - [size] => Array - ( - [0] => 0 - ) - - ) - -) - -[27-Jan-2026 14:57:08 UTC] Form processing error: Veuillez sélectionner au moins une langue. -[27-Jan-2026 15:16:43 UTC] FILES array: Array -( - [couverture] => Array - ( - [name] => - [full_path] => - [type] => - [tmp_name] => - [error] => 4 - [size] => 0 - ) - - [files] => Array - ( - [name] => Array - ( - [0] => - ) - - [full_path] => Array - ( - [0] => - ) - - [type] => Array - ( - [0] => - ) - - [tmp_name] => Array - ( - [0] => - ) - - [error] => Array - ( - [0] => 4 - ) - - [size] => Array - ( - [0] => 0 - ) - - ) - -) - -[27-Jan-2026 15:16:43 UTC] Form processing error: Lien URL invalide. -[27-Jan-2026 15:30:28 UTC] FILES array: Array -( - [couverture] => Array - ( - [name] => - [full_path] => - [type] => - [tmp_name] => - [error] => 4 - [size] => 0 - ) - - [files] => Array - ( - [name] => Array - ( - [0] => - ) - - [full_path] => Array - ( - [0] => - ) - - [type] => Array - ( - [0] => - ) - - [tmp_name] => Array - ( - [0] => - ) - - [error] => Array - ( - [0] => 4 - ) - - [size] => Array - ( - [0] => 0 - ) - - ) - -) - -[27-Jan-2026 15:30:28 UTC] Author ID: 1 -[27-Jan-2026 15:30:28 UTC] Thesis ID: 1 -[27-Jan-2026 15:30:29 UTC] Thesis submission completed successfully: 2026-001 -[27-Jan-2026 15:33:11 UTC] FILES array: Array -( - [couverture] => Array - ( - [name] => - [full_path] => - [type] => - [tmp_name] => - [error] => 4 - [size] => 0 - ) - - [files] => Array - ( - [name] => Array - ( - [0] => - ) - - [full_path] => Array - ( - [0] => - ) - - [type] => Array - ( - [0] => - ) - - [tmp_name] => Array - ( - [0] => - ) - - [error] => Array - ( - [0] => 4 - ) - - [size] => Array - ( - [0] => 0 - ) - - ) - -) - -[27-Jan-2026 15:33:11 UTC] Author ID: 2 -[27-Jan-2026 15:33:11 UTC] Thesis ID: 2 -[27-Jan-2026 15:33:12 UTC] Thesis submission completed successfully: 2026-002 -[27-Jan-2026 15:48:51 UTC] FILES array: Array -( - [couverture] => Array - ( - [name] => - [full_path] => - [type] => - [tmp_name] => - [error] => 4 - [size] => 0 - ) - - [files] => Array - ( - [name] => Array - ( - [0] => - ) - - [full_path] => Array - ( - [0] => - ) - - [type] => Array - ( - [0] => - ) - - [tmp_name] => Array - ( - [0] => - ) - - [error] => Array - ( - [0] => 4 - ) - - [size] => Array - ( - [0] => 0 - ) - - ) - -) - -[27-Jan-2026 15:48:51 UTC] Author ID: 14 -[27-Jan-2026 15:48:51 UTC] Thesis ID: 14 -[27-Jan-2026 15:48:51 UTC] Thesis submission completed successfully: 2026-003 diff --git a/apps/admin/test.db b/apps/admin/test.db deleted file mode 100644 index 5906c60..0000000 Binary files a/apps/admin/test.db and /dev/null differ diff --git a/config/bootstrap.php b/config/bootstrap.php index 75a60bb..54ddfeb 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -24,21 +24,7 @@ if (php_sapi_name() === 'cli-server') { ini_set('log_errors', '1'); } -// Simple helper function for including templates -function include_template($name) -{ - $path = APP_ROOT . '/templates/' . $name; - if (file_exists($path)) { - include $path; - } -} - // Load admin credentials if available (defines ADMIN_PASSWORD_HASH for AdminAuth) if (file_exists(APP_ROOT . '/config/admin_credentials.php')) { require_once APP_ROOT . '/config/admin_credentials.php'; } - -// Autoload Composer dependencies if available -if (file_exists(APP_ROOT . '/vendor/autoload.php')) { - require_once APP_ROOT . '/vendor/autoload.php'; -} diff --git a/public/admin/index.php b/public/admin/index.php index 4feaf86..8bf48c0 100644 --- a/public/admin/index.php +++ b/public/admin/index.php @@ -17,59 +17,25 @@ require_once __DIR__ . '/../../src/Database.php'; try { $db = new Database(); - $pdo = $db->getPDO(); // Get filter parameters $searchQuery = isset($_GET['search']) ? trim($_GET['search']) : ''; $yearFilter = isset($_GET['year']) ? intval($_GET['year']) : null; $orientationFilter = isset($_GET['orientation']) ? intval($_GET['orientation']) : null; - // Build query - $sql = "SELECT - t.id, t.identifier, t.title, t.subtitle, t.year, - o.name as orientation, - ap.name as ap_program, - GROUP_CONCAT(DISTINCT a.name) as authors, - t.submitted_at, - t.is_published - FROM theses t - LEFT JOIN orientations o ON t.orientation_id = o.id - LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id - LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id - LEFT JOIN authors a ON ta.author_id = a.id - WHERE 1=1"; - - $params = []; - + $filters = []; if ($searchQuery) { - $sql .= " AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)"; - $searchParam = "%$searchQuery%"; - $params[] = $searchParam; - $params[] = $searchParam; - $params[] = $searchParam; + $filters['search'] = $searchQuery; } - if ($yearFilter) { - $sql .= " AND t.year = ?"; - $params[] = $yearFilter; + $filters['year'] = $yearFilter; } - if ($orientationFilter) { - $sql .= " AND t.orientation_id = ?"; - $params[] = $orientationFilter; + $filters['orientation'] = $orientationFilter; } - $sql .= " GROUP BY t.id ORDER BY t.year DESC, t.submitted_at DESC"; - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - $theses = $stmt->fetchAll(); - - // Get unique years for filter - $yearsStmt = $pdo->query("SELECT DISTINCT year FROM theses ORDER BY year DESC"); - $years = $yearsStmt->fetchAll(PDO::FETCH_COLUMN); - - // Get orientations for filter + $theses = $db->getThesesList($filters); + $years = $db->getAllYears(); $orientations = $db->getAllOrientations(); } catch (Exception $e) { error_log("Error loading theses list: " . $e->getMessage()); diff --git a/src/Database.php b/src/Database.php index 7a6fa8a..52614a6 100644 --- a/src/Database.php +++ b/src/Database.php @@ -257,13 +257,13 @@ class Database { } /** - * Search theses with filters (secure implementation) + * Build WHERE conditions and named bindings from validated search params. + * Always includes the `is_published = 1` guard. + * + * @param array $params Already-validated params (output of validateSearchParams) + * @return array{0: string[], 1: array} [$conditions, $bindings] */ - public function searchTheses($params = [], $limit = 20, $offset = 0) { - $params = $this->validateSearchParams($params); - $limit = max(1, min(100, intval($limit))); - $offset = max(0, intval($offset)); - + private function buildSearchConditions(array $params): array { $conditions = ["is_published = 1"]; $bindings = []; @@ -319,6 +319,19 @@ class Database { $bindings[':is_doctoral'] = $params['is_doctoral'] ? 1 : 0; } + return [$conditions, $bindings]; + } + + /** + * Search theses with filters (secure implementation) + */ + public function searchTheses(array $params = [], $limit = 20, $offset = 0) { + $params = $this->validateSearchParams($params); + $limit = max(1, min(100, intval($limit))); + $offset = max(0, intval($offset)); + + [$conditions, $bindings] = $this->buildSearchConditions($params); + $whereClause = implode(' AND ', $conditions); $sql = "SELECT * FROM v_theses_public WHERE $whereClause ORDER BY year DESC, title ASC LIMIT :limit OFFSET :offset"; @@ -336,62 +349,10 @@ class Database { /** * Count search results */ - public function countSearchResults($params = []) { + public function countSearchResults(array $params = []) { $params = $this->validateSearchParams($params); - $conditions = ["is_published = 1"]; - $bindings = []; - if (!empty($params['query'])) { - $conditions[] = "( - title LIKE :query ESCAPE '\\' OR - subtitle LIKE :query ESCAPE '\\' OR - synopsis LIKE :query ESCAPE '\\' OR - authors LIKE :query ESCAPE '\\' OR - supervisors LIKE :query ESCAPE '\\' OR - keywords LIKE :query ESCAPE '\\' - )"; - $bindings[':query'] = '%' . $params['query'] . '%'; - } - - if (!empty($params['year'])) { - $conditions[] = "year = :year"; - $bindings[':year'] = $params['year']; - } - - if (!empty($params['orientation'])) { - $conditions[] = "orientation LIKE :orientation ESCAPE '\\'"; - $bindings[':orientation'] = '%' . $params['orientation'] . '%'; - } - - if (!empty($params['ap_program'])) { - $conditions[] = "ap_program LIKE :ap_program ESCAPE '\\'"; - $bindings[':ap_program'] = '%' . $params['ap_program'] . '%'; - } - - if (!empty($params['finality'])) { - $conditions[] = "finality_type LIKE :finality ESCAPE '\\'"; - $bindings[':finality'] = '%' . $params['finality'] . '%'; - } - - if (!empty($params['keyword'])) { - $conditions[] = "keywords LIKE :keyword ESCAPE '\\'"; - $bindings[':keyword'] = '%' . $params['keyword'] . '%'; - } - - if (!empty($params['format'])) { - $conditions[] = "formats LIKE :format ESCAPE '\\'"; - $bindings[':format'] = '%' . $params['format'] . '%'; - } - - if (!empty($params['language'])) { - $conditions[] = "languages LIKE :language ESCAPE '\\'"; - $bindings[':language'] = '%' . $params['language'] . '%'; - } - - if (isset($params['is_doctoral'])) { - $conditions[] = "is_doctoral = :is_doctoral"; - $bindings[':is_doctoral'] = $params['is_doctoral'] ? 1 : 0; - } + [$conditions, $bindings] = $this->buildSearchConditions($params); $whereClause = implode(' AND ', $conditions); $sql = "SELECT COUNT(*) as count FROM v_theses_public WHERE $whereClause"; @@ -508,6 +469,71 @@ class Database { return $this->getLanguages(); } + // ======================================================================== + // ADMIN LIST METHOD + // ======================================================================== + + /** + * Return theses for the admin list view, with optional filters. + * + * Filters (all optional): + * 'search' string – matches title, subtitle, or author name (LIKE) + * 'year' int – exact year match + * 'orientation' int – orientation_id exact match + * + * @param array $filters + * @return array + */ + public function getThesesList(array $filters = []): array { + $sql = "SELECT + t.id, t.identifier, t.title, t.subtitle, t.year, + o.name as orientation, + ap.name as ap_program, + GROUP_CONCAT(DISTINCT a.name) as authors, + t.submitted_at, + t.is_published + FROM theses t + LEFT JOIN orientations o ON t.orientation_id = o.id + LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id + LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id + LEFT JOIN authors a ON ta.author_id = a.id + WHERE 1=1"; + + $params = []; + + if (!empty($filters['search'])) { + $sql .= " AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)"; + $searchParam = '%' . $filters['search'] . '%'; + $params[] = $searchParam; + $params[] = $searchParam; + $params[] = $searchParam; + } + + if (!empty($filters['year'])) { + $sql .= " AND t.year = ?"; + $params[] = intval($filters['year']); + } + + if (!empty($filters['orientation'])) { + $sql .= " AND t.orientation_id = ?"; + $params[] = intval($filters['orientation']); + } + + $sql .= " GROUP BY t.id ORDER BY t.year DESC, t.submitted_at DESC"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + /** + * Get distinct years present in the theses table (admin, includes unpublished). + */ + public function getAllYears(): array { + $stmt = $this->pdo->query("SELECT DISTINCT year FROM theses ORDER BY year DESC"); + return $stmt->fetchAll(PDO::FETCH_COLUMN); + } + // ======================================================================== // CRUD METHODS (from formulaire) // ======================================================================== diff --git a/tests/Integration/SearchTest.php b/tests/Integration/SearchTest.php index cdab595..42de6d0 100644 --- a/tests/Integration/SearchTest.php +++ b/tests/Integration/SearchTest.php @@ -14,7 +14,7 @@ try { // Test 1: Search with empty query echo "Test 1: Empty Search Query\n"; - $results = $db->searchTheses(''); + $results = $db->searchTheses([]); if (is_array($results)) { echo "✓ PASS: Empty query handled (returned " . count($results) . " results)\n\n"; } else { @@ -24,7 +24,7 @@ try { // Test 2: Search for specific term echo "Test 2: Search for Specific Term\n"; $searchTerm = 'art'; // Common word likely to appear - $results = $db->searchTheses($searchTerm); + $results = $db->searchTheses(['query' => $searchTerm]); if (is_array($results)) { echo "✓ PASS: Search for '$searchTerm' returned " . count($results) . " results\n\n"; } else { @@ -33,7 +33,7 @@ try { // Test 3: Search with special characters echo "Test 3: Search with Special Characters\n"; - $results = $db->searchTheses("test's \"quotes\" & symbols"); + $results = $db->searchTheses(['query' => "test's \"quotes\" & symbols"]); if (is_array($results)) { echo "✓ PASS: Special characters handled safely\n\n"; } else {