Files
xamxam/src/Database.php
Pontoporeia 42af4644c5 perf+a11y: WAL mode for SQLite, skip links, :focus-visible, .sr-only
SQLite performance (Database::__construct):
- PRAGMA journal_mode = WAL: eliminates full-DB read locks on write, safe
  for concurrent PHP-FPM workers
- PRAGMA synchronous = NORMAL: durable on commit without full fsync per write
- PRAGMA cache_size = -8000: ~8 MB page cache per connection

Accessibility foundation (WCAG 2.1 AA):
- common.css: add .sr-only utility, .skip-link (hidden until focused),
  global :focus-visible (2px purple outline, 2px offset),
  prefers-reduced-motion guard; remove bare outline:none from
  .site-search__input
- admin.css: same :focus-visible, skip-link, and motion guard scoped to
  admin purple; remove outline:none from .admin-input/.admin-select/
  .admin-textarea and .admin-filters select (both had :focus border rules
  already, so focus is still visually communicated)
- search.css: remove outline:none from .search-filter-select (already has
  :focus border-color rule)
- All 5 public pages (index, search, tfe, apropos, licence): add
  <a href="#main-content" class="skip-link"> as first child of <body>;
  add id="main-content" to <main>
- templates/admin/head.php: same skip link; aria-label="Navigation admin"
  on <nav>; id="main-content" on all 10 admin <main> elements

All 4 test suites pass (unit, integration, security, rate-limit).
2026-03-27 13:45:01 +01:00

949 lines
31 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/config.php';
/**
* Unified Database connection class for Post-ERG thesis database
* Combines functionality from both front-backend and formulaire
* Supports both singleton (front-backend) and direct instantiation (formulaire)
*/
class Database {
private static $instance = null;
private $pdo;
private $dbPath;
/**
* Constructor - public to support both singleton and direct instantiation
* @param string $dbPath Optional database path override
*/
public function __construct($dbPath = null) {
$this->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<string,mixed>} [$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.
*/
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();
}
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 getOrientations() {
$sql = "SELECT * FROM orientations ORDER BY name";
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll();
}
/**
* Alias for formulaire compatibility
*/
public function getAllOrientations() {
return $this->getOrientations();
}
/**
* Get all AP programs
*/
public function getApPrograms() {
$sql = "SELECT * FROM ap_programs ORDER BY name";
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll();
}
/**
* Alias for formulaire compatibility
*/
public function getAllAPPrograms() {
return $this->getApPrograms();
}
/**
* Get all finality types
*/
public function getFinalityTypes() {
$sql = "SELECT * FROM finality_types ORDER BY name";
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll();
}
/**
* Alias for formulaire compatibility
*/
public function getAllFinalityTypes() {
return $this->getFinalityTypes();
}
/**
* 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();
}
/** Backwards-compat alias */
public function getUsedKeywords(): array {
return $this->getUsedTags();
}
/**
* Get all format types
*/
public function getFormatTypes() {
$sql = "SELECT * FROM format_types ORDER BY name";
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll();
}
/**
* Alias for formulaire compatibility
*/
public function getAllFormatTypes() {
return $this->getFormatTypes();
}
/**
* Get all languages
*/
public function getLanguages() {
$sql = "SELECT * FROM languages ORDER BY name";
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll();
}
/**
* Alias for formulaire compatibility
*/
public function getAllLanguages() {
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)
// ========================================================================
/**
* 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();
}
/** Backwards-compat alias */
public function findOrCreateKeyword($keyword): ?int {
return $this->findOrCreateTag((string)$keyword);
}
// ========================================================================
// 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
*/
public function getOrientationId($name) {
$stmt = $this->pdo->prepare("SELECT id FROM orientations WHERE name = ?");
$stmt->execute([$name]);
$result = $stmt->fetch();
return $result ? $result['id'] : null;
}
/**
* Get AP program ID by name
*/
public function getAPProgramId($name) {
$stmt = $this->pdo->prepare("SELECT id FROM ap_programs WHERE name = ?");
$stmt->execute([$name]);
$result = $stmt->fetch();
return $result ? $result['id'] : null;
}
/**
* Get finality type ID by name
*/
public function getFinalityId($name) {
$stmt = $this->pdo->prepare("SELECT id FROM finality_types WHERE name = ?");
$stmt->execute([$name]);
$result = $stmt->fetch();
return $result ? $result['id'] : null;
}
/**
* Get language ID by name
*/
public function getLanguageId($name) {
$stmt = $this->pdo->prepare("SELECT id FROM languages WHERE name = ?");
$stmt->execute([$name]);
$result = $stmt->fetch();
return $result ? $result['id'] : null;
}
/**
* Get format type ID by name
*/
public function getFormatId($name) {
$stmt = $this->pdo->prepare("SELECT id FROM format_types WHERE name = ?");
$stmt->execute([$name]);
$result = $stmt->fetch();
return $result ? $result['id'] : null;
}
// ========================================================================
// 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 getLicenseTypes(): array {
$stmt = $this->pdo->query("SELECT * FROM license_types ORDER BY name");
return $stmt->fetchAll();
}
/**
* Alias for form-loading consistency
*/
public function getAllLicenseTypes(): array {
return $this->getLicenseTypes();
}
// ========================================================================
// 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 {
$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]);
}
}
// ========================================================================
// 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]);
}
/**
* 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");
}
}