dbPath = $this->determineDatabasePath($dbPath); try { $this->pdo = new PDO('sqlite:' . $this->dbPath); $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); // Enable foreign key constraints + performance pragmas $this->pdo->exec('PRAGMA foreign_keys = ON'); $this->pdo->exec('PRAGMA journal_mode = WAL'); $this->pdo->exec('PRAGMA synchronous = NORMAL'); $this->pdo->exec('PRAGMA cache_size = -8000'); } catch (PDOException $e) { error_log("Database connection failed: " . $e->getMessage()); throw new Exception("Impossible de se connecter à la base de données."); } } /** * Determine database path * Uses centralized config from config.php * Priority: custom path > config.php settings */ private function determineDatabasePath($customPath = null) { // Allow explicit override if ($customPath !== null && file_exists($customPath)) { return $customPath; } // Use centralized configuration return getDatabasePath(); } /** * Get singleton instance (for front-backend) * @return Database */ public static function getInstance() { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; } /** * Get PDO connection * @return PDO */ public function getConnection() { return $this->pdo; } /** * Get PDO instance (alias for formulaire compatibility) * @return PDO */ public function getPDO() { return $this->pdo; } // ======================================================================== // TRANSACTION SUPPORT (from formulaire) // ======================================================================== /** * Begin a transaction */ public function beginTransaction() { return $this->pdo->beginTransaction(); } /** * Commit a transaction */ public function commit() { return $this->pdo->commit(); } /** * Rollback a transaction */ public function rollback() { return $this->pdo->rollback(); } // ======================================================================== // PUBLIC SITE METHODS (from front-backend) // ======================================================================== /** * Get all published theses with pagination */ public function getPublishedTheses($limit = 10, $offset = 0) { $sql = "SELECT * FROM v_theses_public ORDER BY year DESC, title ASC LIMIT :limit OFFSET :offset"; $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } /** * Get theses from the latest published year, in random order (per request). * Used for the default home page view. */ public function getLatestYearTheses(int $limit = 24): array { $sql = "SELECT * FROM v_theses_public WHERE year = (SELECT MAX(year) FROM theses WHERE is_published = 1) ORDER BY RANDOM() LIMIT :limit"; $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } /** * Get the latest year that has published theses */ public function getLatestPublishedYear(): ?int { $stmt = $this->pdo->query("SELECT MAX(year) FROM theses WHERE is_published = 1"); $val = $stmt->fetchColumn(); return $val ? (int)$val : null; } /** * Count all published theses */ public function countPublishedTheses() { $sql = "SELECT COUNT(*) as count FROM theses WHERE is_published = 1"; $stmt = $this->pdo->query($sql); $result = $stmt->fetch(); return $result['count']; } /** * Get thesis by ID with all related data (for public site) */ public function getThesisById($id) { $sql = "SELECT * FROM v_theses_full WHERE id = :id AND is_published = 1"; $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':id', $id, PDO::PARAM_INT); $stmt->execute(); $thesis = $stmt->fetch(); if (!$thesis) { return null; } // Get associated files $thesis['files'] = $this->getThesisFiles($id); return $thesis; } /** * Get thesis by ID (for admin - includes unpublished) * @param int $id Thesis ID * @return array|null Thesis data or null if not found */ public function getThesis($id) { $stmt = $this->pdo->prepare("SELECT * FROM v_theses_full WHERE id = ?"); $stmt->execute([$id]); return $stmt->fetch(); } /** * Get files associated with a thesis */ public function getThesisFiles($thesisId) { $sql = "SELECT * FROM thesis_files WHERE thesis_id = :thesis_id ORDER BY file_type, uploaded_at"; $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':thesis_id', $thesisId, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } // ======================================================================== // SEARCH FUNCTIONALITY (from front-backend - secure implementation) // ======================================================================== /** * Escape LIKE wildcards in user input to prevent wildcard injection */ private function escapeLikeString($string) { return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $string); } /** * Validate and sanitize search parameters * @throws InvalidArgumentException if validation fails */ private function validateSearchParams($params) { $validated = []; if (!empty($params['query'])) { $query = trim($params['query']); if (strlen($query) > 200) { throw new InvalidArgumentException("Search query too long (max 200 characters)"); } $validated['query'] = $this->escapeLikeString($query); } if (!empty($params['year'])) { $year = intval($params['year']); if ($year < 1900 || $year > 2100) { throw new InvalidArgumentException("Invalid year"); } $validated['year'] = $year; } if (!empty($params['orientation'])) { $orientation = trim($params['orientation']); if (strlen($orientation) > 100) { throw new InvalidArgumentException("Orientation name too long"); } $validated['orientation'] = $this->escapeLikeString($orientation); } if (!empty($params['ap_program'])) { $ap = trim($params['ap_program']); if (strlen($ap) > 100) { throw new InvalidArgumentException("AP program name too long"); } $validated['ap_program'] = $this->escapeLikeString($ap); } if (!empty($params['finality'])) { $finality = trim($params['finality']); if (strlen($finality) > 100) { throw new InvalidArgumentException("Finality name too long"); } $validated['finality'] = $this->escapeLikeString($finality); } if (!empty($params['keyword'])) { $keyword = trim($params['keyword']); if (strlen($keyword) > 100) { throw new InvalidArgumentException("Keyword too long"); } $validated['keyword'] = $this->escapeLikeString($keyword); } if (!empty($params['format'])) { $format = trim($params['format']); if (strlen($format) > 100) { throw new InvalidArgumentException("Format name too long"); } $validated['format'] = $this->escapeLikeString($format); } if (!empty($params['language'])) { $language = trim($params['language']); if (strlen($language) > 50) { throw new InvalidArgumentException("Language name too long"); } $validated['language'] = $this->escapeLikeString($language); } if (isset($params['is_doctoral'])) { $validated['is_doctoral'] = (bool)$params['is_doctoral']; } return $validated; } /** * 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] */ private function buildSearchConditions(array $params): array { $conditions = ["vp.is_published = 1"]; $bindings = []; if (!empty($params['query'])) { $conditions[] = "( vp.title LIKE :query ESCAPE '\\' OR vp.subtitle LIKE :query ESCAPE '\\' OR vp.synopsis LIKE :query ESCAPE '\\' OR vp.authors LIKE :query ESCAPE '\\' OR vp.supervisors LIKE :query ESCAPE '\\' OR EXISTS ( SELECT 1 FROM thesis_tags tt2 JOIN tags tg2 ON tg2.id = tt2.tag_id WHERE tt2.thesis_id = vp.id AND tg2.name LIKE :query ESCAPE '\\' ) )"; $bindings[':query'] = '%' . $params['query'] . '%'; } if (!empty($params['year'])) { $conditions[] = "vp.year = :year"; $bindings[':year'] = $params['year']; } if (!empty($params['orientation'])) { $conditions[] = "vp.orientation LIKE :orientation ESCAPE '\\'"; $bindings[':orientation'] = '%' . $params['orientation'] . '%'; } if (!empty($params['ap_program'])) { $conditions[] = "vp.ap_program LIKE :ap_program ESCAPE '\\'"; $bindings[':ap_program'] = '%' . $params['ap_program'] . '%'; } if (!empty($params['finality'])) { $conditions[] = "vp.finality_type LIKE :finality ESCAPE '\\'"; $bindings[':finality'] = '%' . $params['finality'] . '%'; } if (!empty($params['keyword'])) { $conditions[] = "EXISTS ( SELECT 1 FROM thesis_tags tt_kw JOIN tags tg_kw ON tg_kw.id = tt_kw.tag_id WHERE tt_kw.thesis_id = vp.id AND tg_kw.name LIKE :keyword ESCAPE '\\' )"; $bindings[':keyword'] = '%' . $params['keyword'] . '%'; } if (!empty($params['format'])) { $conditions[] = "vp.formats LIKE :format ESCAPE '\\'"; $bindings[':format'] = '%' . $params['format'] . '%'; } if (!empty($params['language'])) { $conditions[] = "vp.languages LIKE :language ESCAPE '\\'"; $bindings[':language'] = '%' . $params['language'] . '%'; } if (isset($params['is_doctoral'])) { $conditions[] = "vp.is_doctoral = :is_doctoral"; $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 vp.* FROM v_theses_public vp WHERE $whereClause ORDER BY vp.year DESC, vp.title ASC LIMIT :limit OFFSET :offset"; $stmt = $this->pdo->prepare($sql); foreach ($bindings as $key => $value) { $stmt->bindValue($key, $value); } $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } /** * Count search results */ public function countSearchResults(array $params = []) { $params = $this->validateSearchParams($params); [$conditions, $bindings] = $this->buildSearchConditions($params); $whereClause = implode(' AND ', $conditions); $sql = "SELECT COUNT(*) as count FROM v_theses_public vp WHERE $whereClause"; $stmt = $this->pdo->prepare($sql); foreach ($bindings as $key => $value) { $stmt->bindValue($key, $value); } $stmt->execute(); $result = $stmt->fetch(); return $result['count']; } /** * Get all available years from published theses */ /** * Return ALL published theses (no cap) — for internal use (student index). * Not exposed to user-controlled input. * * @deprecated Use getPublishedAuthors() for the répertoire student index — * it avoids the expensive v_theses_public view and only fetches * the two columns actually needed (id, authors). */ public function getAllPublishedTheses(): array { $stmt = $this->pdo->query( "SELECT vp.* FROM v_theses_public vp ORDER BY vp.year DESC, vp.title ASC" ); return $stmt->fetchAll(); } /** * Lean query for the répertoire student-name index. * * Avoids the fat v_theses_public view (15 JOINs + 8 GROUP_CONCAT B-trees). * Queries thesis_authors → authors directly, letting the index on * theses(is_published) filter the base table before any JOIN. * * Returns rows of [id => int, authors => "Name1,Name2"]. */ public function getPublishedAuthors(): array { $stmt = $this->pdo->query( "SELECT t.id, GROUP_CONCAT(a.name ORDER BY ta.author_order ASC) AS authors FROM theses t JOIN thesis_authors ta ON ta.thesis_id = t.id JOIN authors a ON a.id = ta.author_id WHERE t.is_published = 1 GROUP BY t.id ORDER BY MIN(a.name) ASC" ); return $stmt->fetchAll(); } public function getAvailableYears() { $sql = "SELECT DISTINCT year FROM theses WHERE is_published = 1 ORDER BY year DESC"; $stmt = $this->pdo->query($sql); return $stmt->fetchAll(PDO::FETCH_COLUMN); } /** * Get all orientations */ public function getAllOrientations(): array { $stmt = $this->pdo->query("SELECT * FROM orientations ORDER BY name"); return $stmt->fetchAll(); } /** * Get all AP programs */ public function getAllAPPrograms(): array { $stmt = $this->pdo->query("SELECT * FROM ap_programs ORDER BY name"); return $stmt->fetchAll(); } /** * Get all finality types */ public function getAllFinalityTypes(): array { $stmt = $this->pdo->query("SELECT * FROM finality_types ORDER BY name"); return $stmt->fetchAll(); } /** * Get all keywords used in published theses */ public function getUsedTags(): array { $sql = "SELECT DISTINCT tg.id, tg.name FROM tags tg JOIN thesis_tags tt ON tg.id = tt.tag_id JOIN theses th ON tt.thesis_id = th.id WHERE th.is_published = 1 ORDER BY tg.name"; $stmt = $this->pdo->query($sql); return $stmt->fetchAll(); } /** * Get all format types */ public function getAllFormatTypes(): array { $stmt = $this->pdo->query("SELECT * FROM format_types ORDER BY name"); return $stmt->fetchAll(); } /** * Get all languages */ public function getAllLanguages(): array { $stmt = $this->pdo->query("SELECT * FROM languages ORDER BY name"); return $stmt->fetchAll(); } // ======================================================================== // 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); } /** * Return whole-database thesis counts, independent of any active filter. * * Always reflects the full theses table so the stats bar in admin/index.php * shows accurate numbers even when a search or year filter is active. * * @return array{total: int, published: int, pending: int} */ public function getThesesStats(): array { $stmt = $this->pdo->query( "SELECT COUNT(*) AS total, SUM(CASE WHEN is_published = 1 THEN 1 ELSE 0 END) AS published, SUM(CASE WHEN is_published = 0 THEN 1 ELSE 0 END) AS pending FROM theses" ); $row = $stmt->fetch(); return [ 'total' => (int) $row['total'], 'published' => (int) $row['published'], 'pending' => (int) $row['pending'], ]; } // ======================================================================== // CRUD METHODS (from formulaire) // ======================================================================== /** * Find or create an author */ public function findOrCreateAuthor($name, $email = null) { $stmt = $this->pdo->prepare("SELECT id FROM authors WHERE name = ?"); $stmt->execute([$name]); $author = $stmt->fetch(); if ($author) { if ($email && $email !== '') { $updateStmt = $this->pdo->prepare("UPDATE authors SET email = ? WHERE id = ?"); $updateStmt->execute([$email, $author['id']]); } return $author['id']; } $stmt = $this->pdo->prepare("INSERT INTO authors (name, email) VALUES (?, ?)"); $stmt->execute([$name, $email]); return $this->pdo->lastInsertId(); } /** * Find or create a supervisor */ public function findOrCreateSupervisor($name) { $stmt = $this->pdo->prepare("SELECT id FROM supervisors WHERE name = ?"); $stmt->execute([$name]); $supervisor = $stmt->fetch(); if ($supervisor) { return $supervisor['id']; } $stmt = $this->pdo->prepare("INSERT INTO supervisors (name) VALUES (?)"); $stmt->execute([$name]); return $this->pdo->lastInsertId(); } /** * Find or create a tag (formerly keyword) */ public function findOrCreateTag(string $name): ?int { $name = trim($name); if ($name === '') { return null; } $stmt = $this->pdo->prepare("SELECT id FROM tags WHERE name = ?"); $stmt->execute([$name]); $row = $stmt->fetch(); if ($row) { return (int)$row['id']; } $stmt = $this->pdo->prepare("INSERT INTO tags (name) VALUES (?)"); $stmt->execute([$name]); return (int)$this->pdo->lastInsertId(); } // ======================================================================== // TAG MANAGEMENT (admin) // ======================================================================== /** * Return all tags with a count of associated (published) theses. */ public function getAllTagsWithCount(): array { $stmt = $this->pdo->query(" SELECT tg.id, tg.name, COUNT(DISTINCT tt.thesis_id) as thesis_count FROM tags tg LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id GROUP BY tg.id ORDER BY tg.name COLLATE NOCASE "); return $stmt->fetchAll(); } /** * Rename a tag. Throws if the new name already exists. */ public function renameTag(int $id, string $newName): void { $newName = trim($newName); if ($newName === '') throw new Exception("Le nom du tag ne peut pas être vide."); // Check uniqueness $stmt = $this->pdo->prepare("SELECT id FROM tags WHERE name = ? AND id != ?"); $stmt->execute([$newName, $id]); if ($stmt->fetch()) throw new Exception("Un tag avec ce nom existe déjà."); $this->pdo->prepare("UPDATE tags SET name = ? WHERE id = ?")->execute([$newName, $id]); } /** * Merge sourceId into targetId: reassign all thesis_tags rows, then delete source. * Uses INSERT OR IGNORE to avoid PK conflicts. */ public function mergeTag(int $sourceId, int $targetId): void { if ($sourceId === $targetId) throw new Exception("Source et destination identiques."); $this->pdo->beginTransaction(); try { // Re-point thesis_tags rows from source → target (skip conflicts) $this->pdo->prepare(" INSERT OR IGNORE INTO thesis_tags (tag_id, thesis_id) SELECT ?, thesis_id FROM thesis_tags WHERE tag_id = ? ")->execute([$targetId, $sourceId]); // Delete the old source rows $this->pdo->prepare("DELETE FROM thesis_tags WHERE tag_id = ?")->execute([$sourceId]); // Delete the source tag itself $this->pdo->prepare("DELETE FROM tags WHERE id = ?")->execute([$sourceId]); $this->pdo->commit(); } catch (\Throwable $e) { $this->pdo->rollBack(); throw $e; } } /** * Delete a tag and all its thesis_tags rows (cascades via FK). */ public function deleteTag(int $id): void { $this->pdo->prepare("DELETE FROM tags WHERE id = ?")->execute([$id]); } /** * Get orientation ID by name */ // ======================================================================== // STATIC PAGES METHODS // ======================================================================== /** * Get a static page by slug * @param string $slug Page slug (e.g. 'about', 'licenses') * @return array|null */ public function getPage(string $slug): ?array { $stmt = $this->pdo->prepare("SELECT * FROM pages WHERE slug = ?"); $stmt->execute([$slug]); $row = $stmt->fetch(); return $row ?: null; } /** * Update content for a static page by slug * @throws Exception if slug not found */ public function savePage(string $slug, string $content): void { $stmt = $this->pdo->prepare("SELECT id FROM pages WHERE slug = ?"); $stmt->execute([$slug]); if (!$stmt->fetch()) { throw new Exception("Page slug not found: $slug"); } $stmt = $this->pdo->prepare( "UPDATE pages SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?" ); $stmt->execute([$content, $slug]); } /** * Get all static pages */ public function getAllPages(): array { $stmt = $this->pdo->query("SELECT * FROM pages ORDER BY slug"); return $stmt->fetchAll(); } // ======================================================================== // LICENSE TYPE METHODS // ======================================================================== /** * Get all license types ordered by name */ public function getAllLicenseTypes(): array { $stmt = $this->pdo->query("SELECT * FROM license_types ORDER BY name"); return $stmt->fetchAll(); } // ======================================================================== // VISIBILITY METHODS // ======================================================================== /** * Set the access_type_id (visibility) for a single thesis. * @param int $thesisId * @param int|null $accessTypeId 1=Libre, 2=Interne, 3=Interdit, null=unset */ public function setVisibility(int $thesisId, ?int $accessTypeId): void { $stmt = $this->pdo->prepare( "UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" ); $stmt->execute([$accessTypeId, $thesisId]); } /** * Set visibility for multiple theses at once. * @param int[] $thesisIds * @param int|null $accessTypeId */ public function bulkSetVisibility(array $thesisIds, ?int $accessTypeId): void { if (empty($thesisIds)) return; $placeholders = implode(',', array_fill(0, count($thesisIds), '?')); $params = array_merge([$accessTypeId], $thesisIds); $this->pdo->prepare( "UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)" )->execute($params); } /** * Get all access types (visibility options). */ public function getAccessTypes(): array { $stmt = $this->pdo->query("SELECT * FROM access_types ORDER BY id"); return $stmt->fetchAll(); } // ======================================================================== // JURY METHODS // ======================================================================== /** * Fetch all jury members for a thesis, with role and is_external flag. */ public function getThesisJury(int $thesisId): array { $stmt = $this->pdo->prepare(" SELECT s.id, s.name, ts.role, ts.is_external, ts.supervisor_order FROM thesis_supervisors ts JOIN supervisors s ON s.id = ts.supervisor_id WHERE ts.thesis_id = ? ORDER BY ts.supervisor_order "); $stmt->execute([$thesisId]); return $stmt->fetchAll(); } /** * Replace the entire jury for a thesis in a single transaction. * $juryMembers: array of ['name' => string, 'role' => string, 'is_external' => int] */ public function setThesisJury(int $thesisId, array $juryMembers): void { $alreadyInTransaction = $this->pdo->inTransaction(); if (!$alreadyInTransaction) { $this->pdo->beginTransaction(); } try { $this->pdo->prepare("DELETE FROM thesis_supervisors WHERE thesis_id = ?")->execute([$thesisId]); $stmt = $this->pdo->prepare(" INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, supervisor_order) VALUES (?, ?, ?, ?, ?) "); foreach ($juryMembers as $order => $member) { $name = trim($member['name'] ?? ''); if ($name === '') continue; $supervisorId = $this->findOrCreateSupervisor($name); $role = in_array($member['role'], ['president', 'promoteur', 'lecteur']) ? $member['role'] : 'promoteur'; $isExternal = isset($member['is_external']) ? (int)$member['is_external'] : 0; $stmt->execute([$thesisId, $supervisorId, $role, $isExternal, $order + 1]); } if (!$alreadyInTransaction) { $this->pdo->commit(); } } catch (\Throwable $e) { if (!$alreadyInTransaction && $this->pdo->inTransaction()) { $this->pdo->rollBack(); } throw $e; } } // ======================================================================== // JUNCTION-TABLE HELPERS (delete-then-reinsert pattern) // ======================================================================== /** * Replace all language associations for a thesis. * @param int $thesisId * @param int[] $languageIds IDs from the languages table */ public function setThesisLanguages(int $thesisId, array $languageIds): void { $this->pdo->prepare("DELETE FROM thesis_languages WHERE thesis_id = ?")->execute([$thesisId]); $stmt = $this->pdo->prepare( "INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?, ?)" ); foreach ($languageIds as $langId) { $id = (int)$langId; if ($id > 0) { $stmt->execute([$thesisId, $id]); } } } /** * Replace all format associations for a thesis. * @param int $thesisId * @param int[] $formatIds IDs from the format_types table */ public function setThesisFormats(int $thesisId, array $formatIds): void { $this->pdo->prepare("DELETE FROM thesis_formats WHERE thesis_id = ?")->execute([$thesisId]); $stmt = $this->pdo->prepare( "INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?)" ); foreach ($formatIds as $fmtId) { $id = (int)$fmtId; if ($id > 0) { $stmt->execute([$thesisId, $id]); } } } /** * Replace all tag associations for a thesis. * Tags are identified by name (findOrCreateTag is called for each). * Empty / whitespace-only names are silently skipped. * Maximum 10 tags are accepted; extras are ignored. * * @param int $thesisId * @param string[] $tagNames */ public function setThesisTags(int $thesisId, array $tagNames): void { $this->pdo->prepare("DELETE FROM thesis_tags WHERE thesis_id = ?")->execute([$thesisId]); $stmt = $this->pdo->prepare( "INSERT OR IGNORE INTO thesis_tags (tag_id, thesis_id) VALUES (?, ?)" ); $count = 0; foreach ($tagNames as $name) { if ($count >= 10) break; $tagId = $this->findOrCreateTag($name); // trims, returns null for empty if ($tagId !== null) { $stmt->execute([$tagId, $thesisId]); $count++; } } } // ======================================================================== // BANNER METHODS // ======================================================================== /** * Set (or clear) the banner_path for a thesis. */ public function setBannerPath(int $thesisId, ?string $path): void { $stmt = $this->pdo->prepare( "UPDATE theses SET banner_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" ); $stmt->execute([$path, $thesisId]); } /** * Process a banner image upload for a thesis. * * Validates MIME type, extension, and file size, then saves the file to the * banners/ directory under STORAGE_ROOT and calls setBannerPath(). * * Returns the relative path (e.g. "banners/abc123.jpg") on success, * or null if the file array is absent, has an error, fails validation, * or cannot be moved. * * @param int $thesisId Target thesis ID * @param array|null $uploadedFile Entry from $_FILES (e.g. $_FILES['banner']) * @return string|null Relative path stored in the DB, or null */ public function handleBannerUpload(int $thesisId, ?array $uploadedFile): ?string { if (!$uploadedFile || ($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { return null; } $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']; $allowedExts = ['jpg', 'jpeg', 'png', 'webp']; $maxBytes = 5 * 1024 * 1024; // 5 MB $finfo = new finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->file($uploadedFile['tmp_name']); $ext = strtolower(pathinfo($uploadedFile['name'], PATHINFO_EXTENSION)); if (!in_array($mimeType, $allowedMimes, true) || !in_array($ext, $allowedExts, true) || $uploadedFile['size'] > $maxBytes) { error_log("handleBannerUpload: rejected " . $uploadedFile['name'] . " ($mimeType, {$uploadedFile['size']} bytes)"); return null; } $bannerDir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/banners/' : null; if (!$bannerDir) { error_log("handleBannerUpload: STORAGE_ROOT not defined"); return null; } if (!file_exists($bannerDir)) { mkdir($bannerDir, 0755, true); } $safeName = bin2hex(random_bytes(16)) . '.' . $ext; $targetPath = $bannerDir . $safeName; if (!move_uploaded_file($uploadedFile['tmp_name'], $targetPath)) { error_log("handleBannerUpload: move_uploaded_file failed for " . $uploadedFile['name']); return null; } chmod($targetPath, 0644); $relativePath = 'banners/' . $safeName; $this->setBannerPath($thesisId, $relativePath); error_log("handleBannerUpload: saved $relativePath"); return $relativePath; } // ======================================================================== // ENCAPSULATED QUERY HELPERS // ======================================================================== /** * Return the raw access_type_id for a thesis (used for visibility gating). * Returns null if the thesis is not found. */ public function getThesisAccessTypeId(int $thesisId): ?int { $stmt = $this->pdo->prepare( "SELECT access_type_id FROM theses WHERE id = ? LIMIT 1" ); $stmt->execute([$thesisId]); $val = $stmt->fetchColumn(); return ($val !== false) ? (int)$val : null; } /** * Return the raw FK fields not exposed through v_theses_full string columns. * Returns ['license_id', 'access_type_id', 'context_note'] or null if not found. * * @return array{license_id:int|null,access_type_id:int|null,context_note:string}|null */ public function getThesisRawFields(int $thesisId): ?array { $stmt = $this->pdo->prepare( "SELECT license_id, access_type_id, context_note FROM theses WHERE id = ? LIMIT 1" ); $stmt->execute([$thesisId]); $row = $stmt->fetch(); return $row !== false ? $row : null; } /** * Return the banner_path for a thesis, or null. * Used when we need just the banner path without the full view expansion. */ public function getThesisBannerPath(int $thesisId): ?string { $stmt = $this->pdo->prepare( "SELECT banner_path FROM theses WHERE id = ? LIMIT 1" ); $stmt->execute([$thesisId]); $val = $stmt->fetchColumn(); return ($val !== false && $val !== null) ? (string)$val : null; } /** * Batch-load cover file paths for a set of thesis IDs. * Returns [thesis_id => file_path] for IDs that have a cover in thesis_files. * * @param int[] $thesisIds * @return array */ public function getCoverPathsForTheses(array $thesisIds): array { if (empty($thesisIds)) { return []; } $placeholders = implode(',', array_fill(0, count($thesisIds), '?')); $stmt = $this->pdo->prepare(" SELECT thesis_id, file_path FROM thesis_files WHERE file_type = 'cover' AND thesis_id IN ($placeholders) "); $stmt->execute($thesisIds); $map = []; foreach ($stmt->fetchAll() as $row) { $map[(int)$row['thesis_id']] = $row['file_path']; } return $map; } /** * Check visibility for a file path under theses/. * Returns the access_type_id of the owning thesis, or null if the file * is not found or the path does not belong to a thesis file. * * Access type 3 = Interdit (forbidden). */ public function getFileVisibility(string $filePath): ?int { $stmt = $this->pdo->prepare(" SELECT t.access_type_id FROM theses t JOIN thesis_files tf ON tf.thesis_id = t.id WHERE tf.file_path = ? LIMIT 1 "); $stmt->execute([$filePath]); $val = $stmt->fetchColumn(); return ($val !== false) ? (int)$val : null; } /** * Return total number of rows in the theses table (for system status display). */ public function getThesisCount(): int { return (int)$this->pdo->query("SELECT COUNT(*) FROM theses")->fetchColumn(); } /** * Generate a unique YYYY-NNN identifier for a new thesis in the given year. * Counts existing theses for that year (published or not) to determine the next sequence * number. Must be called inside the same transaction that performs the INSERT so that * concurrent requests cannot produce duplicate identifiers. */ public function generateThesisIdentifier(int $year): string { $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM theses WHERE year = ?"); $stmt->execute([$year]); $count = (int)$stmt->fetchColumn() + 1; return sprintf("%d-%03d", $year, $count); } /** * Insert a new thesis row, link its author, and return the new thesis ID. * * Expected keys in $data: * year (int), orientation_id (int), ap_program_id (int), finality_id (int), * title (string), subtitle (?string), synopsis (string), * file_size_info (?string), baiu_link (?string), license_id (?int), * author_id (int) * * The identifier is generated automatically from $data['year']. * Must be called inside an open transaction. * * @return int The new thesis ID. */ public function createThesis(array $data): int { $identifier = $this->generateThesisIdentifier((int)$data['year']); $stmt = $this->pdo->prepare(" INSERT INTO theses ( identifier, title, subtitle, year, orientation_id, ap_program_id, finality_id, synopsis, file_size_info, baiu_link, license_id, submitted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) "); $stmt->execute([ $identifier, $data['title'], !empty($data['subtitle']) ? $data['subtitle'] : null, (int)$data['year'], (int)$data['orientation_id'], (int)$data['ap_program_id'], (int)$data['finality_id'], $data['synopsis'], !empty($data['file_size_info']) ? $data['file_size_info'] : null, !empty($data['baiu_link']) ? $data['baiu_link'] : null, isset($data['license_id']) ? $data['license_id'] : null, ]); $thesisId = (int)$this->pdo->lastInsertId(); // Link author — always author_order = 1 for single-author submissions. $stmt = $this->pdo->prepare( "INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, 1)" ); $stmt->execute([$thesisId, (int)$data['author_id']]); return $thesisId; } /** * Return the stored identifier string (e.g. "2024-003") for an existing thesis. */ public function getThesisIdentifier(int $thesisId): string { $stmt = $this->pdo->prepare("SELECT identifier FROM theses WHERE id = ?"); $stmt->execute([$thesisId]); $row = $stmt->fetch(); if (!$row) { throw new \RuntimeException("Thesis #$thesisId not found"); } return (string)$row['identifier']; } /** * Insert a thesis file record */ public function insertThesisFile($thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType) { $stmt = $this->pdo->prepare(" INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?) "); $stmt->execute([$thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType]); return $this->pdo->lastInsertId(); } // ======================================================================== // SINGLETON PATTERN ENFORCEMENT // ======================================================================== /** * Prevent cloning */ private function __clone() {} /** * Prevent unserialization */ public function __wakeup() { throw new Exception("Cannot unserialize singleton"); } }