Files
xamxam/src/Database.php

1703 lines
62 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;
}
/**
* Return the resolved path of the database file in use.
*/
public function getDatabasePath(): string {
return $this->dbPath;
}
// ========================================================================
// 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.
*
* @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();
}
/**
* Compute répertoire filter data.
*
* Given a set of active filters (each an array of values, combined as AND
* across filter types, OR within each filter type), returns:
* - matched_ids : int[] thesis IDs matching ALL active filters
* - years : array all years with matched flag
* - ap_programs : array all AP programs with matched flag
* - orientations : array all orientations with matched flag
* - finality_types: array all finality types with matched flag
* - keywords : array all used keywords with matched flag
* - students : array [id, authors] rows for matched theses only
*
* For each column, "matched" means the value appears in at least one thesis
* that satisfies all the OTHER active filters (excluding that column's own
* filter when computing its own relevance).
*
* @param array{years:int[], ap:string[], or:string[], fi:string[], kw:string[]} $filters
*/
public function getRepertoireFilterData(array $filters): array {
$baseJoins = "
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 finality_types ft ON t.finality_id = ft.id
";
// Build WHERE + bindings excluding one dimension (for that column's own relevance)
$buildWhere = function(string $exclude) use ($filters): array {
$conditions = ['t.is_published = 1'];
$bindings = [];
if ($exclude !== 'years' && !empty($filters['years'])) {
$ph = implode(',', array_fill(0, count($filters['years']), '?'));
$conditions[] = "t.year IN ($ph)";
foreach ($filters['years'] as $v) $bindings[] = (int)$v;
}
if ($exclude !== 'ap' && !empty($filters['ap'])) {
$ph = implode(',', array_fill(0, count($filters['ap']), '?'));
$conditions[] = "ap.name IN ($ph)";
foreach ($filters['ap'] as $v) $bindings[] = (string)$v;
}
if ($exclude !== 'or' && !empty($filters['or'])) {
$ph = implode(',', array_fill(0, count($filters['or']), '?'));
$conditions[] = "o.name IN ($ph)";
foreach ($filters['or'] as $v) $bindings[] = (string)$v;
}
if ($exclude !== 'fi' && !empty($filters['fi'])) {
$ph = implode(',', array_fill(0, count($filters['fi']), '?'));
$conditions[] = "ft.name IN ($ph)";
foreach ($filters['fi'] as $v) $bindings[] = (string)$v;
}
if ($exclude !== 'kw' && !empty($filters['kw'])) {
foreach ($filters['kw'] as $kv) {
$conditions[] = 'EXISTS (SELECT 1 FROM thesis_tags tt2 JOIN tags tg2 ON tg2.id=tt2.tag_id WHERE tt2.thesis_id=t.id AND tg2.name=?)';
$bindings[] = (string)$kv;
}
}
return [implode(' AND ', $conditions), $bindings];
};
$exec = function(string $sql, array $b): array {
$stmt = $this->pdo->prepare($sql);
$stmt->execute($b);
return $stmt->fetchAll();
};
// Full intersection — matched thesis IDs
[$wAll, $bAll] = $buildWhere('__none__');
$matchedIds = array_column($exec("SELECT t.id $baseJoins WHERE $wAll", $bAll), 'id');
// Years
[$w, $b] = $buildWhere('years');
$matchedYears = array_column($exec("SELECT DISTINCT t.year $baseJoins WHERE $w ORDER BY t.year DESC", $b), 'year');
$allYears = array_column($exec("SELECT DISTINCT year FROM theses WHERE is_published=1 ORDER BY year DESC", []), 'year');
$yearsOut = array_map(fn($y) => ['value' => $y, 'matched' => in_array($y, $matchedYears, true)], $allYears);
// AP programs
[$w, $b] = $buildWhere('ap');
$matchedAp = array_column($exec("SELECT DISTINCT ap.name $baseJoins WHERE $w AND ap.name IS NOT NULL ORDER BY ap.name", $b), 'name');
$allAp = array_column($exec("SELECT name FROM ap_programs ORDER BY name", []), 'name');
$apOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedAp, true)], $allAp);
// Orientations
[$w, $b] = $buildWhere('or');
$matchedOr = array_column($exec("SELECT DISTINCT o.name $baseJoins WHERE $w AND o.name IS NOT NULL ORDER BY o.name", $b), 'name');
$allOr = array_column($exec("SELECT name FROM orientations ORDER BY name", []), 'name');
$orOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedOr, true)], $allOr);
// Finality types
[$w, $b] = $buildWhere('fi');
$matchedFi = array_column($exec("SELECT DISTINCT ft.name $baseJoins WHERE $w AND ft.name IS NOT NULL ORDER BY ft.name", $b), 'name');
$allFi = array_column($exec("SELECT name FROM finality_types ORDER BY name", []), 'name');
$fiOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedFi, true)], $allFi);
// Keywords
[$w, $b] = $buildWhere('kw');
$matchedKw = array_column($exec(
"SELECT DISTINCT tg.name $baseJoins
JOIN thesis_tags tt ON tt.thesis_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE $w ORDER BY tg.name", $b), 'name');
$allKw = array_column($exec(
"SELECT DISTINCT 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", []), 'name');
$kwOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedKw, true)], $allKw);
// Students (output only — full intersection)
$studentsOut = [];
if (!empty($matchedIds)) {
$ph = implode(',', array_fill(0, count($matchedIds), '?'));
$studentsOut = $exec(
"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.id IN ($ph)
GROUP BY t.id
ORDER BY MIN(a.name) ASC",
$matchedIds
);
}
return [
'matched_ids' => $matchedIds,
'years' => $yearsOut,
'ap_programs' => $apOut,
'orientations' => $orOut,
'finality_types' => $fiOut,
'keywords' => $kwOut,
'students' => $studentsOut,
];
}
/**
* 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
*/
/**
* Allowed sort columns for the admin list.
* Maps query-string `sort` values to safe SQL ORDER BY expressions.
*/
private const SORT_MAP = [
'id' => 't.id',
'identifier' => 't.identifier',
'title' => 't.title',
'year' => 't.year',
'orientation' => 'o.name',
'ap_program' => 'ap.name',
'is_published' => 't.is_published',
'submitted_at' => 't.submitted_at',
];
/**
* Build the ORDER BY clause from sort/direction parameters.
* Returns a safe SQL fragment (never interpolates raw user input).
*/
private function buildOrderBy(array $filters): string {
$sort = $filters['sort'] ?? 'submitted_at';
$dir = isset($filters['dir']) && strtolower($filters['dir']) === 'asc' ? 'ASC' : 'DESC';
$col = self::SORT_MAP[$sort] ?? self::SORT_MAP['submitted_at'];
// Secondary sort for stable ordering
$secondary = ($sort === 'year') ? ', t.title ASC' : ', t.id DESC';
return "ORDER BY {$col} {$dir}{$secondary}";
}
/**
* Count theses matching the given admin filters (no LIMIT).
* Used alongside getThesesList() to calculate total pages.
*/
public function getThesesListCount(array $filters = []): int {
$sql = "SELECT COUNT(DISTINCT t.id)
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']);
}
if (!empty($filters['ap'])) {
$sql .= " AND t.ap_program_id = ?";
$params[] = intval($filters['ap']);
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetchColumn();
}
public function getThesesList(array $filters = [], int $limit = 0, int $offset = 0): 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,
at.name as access_type
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
LEFT JOIN access_types at ON t.access_type_id = at.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']);
}
if (!empty($filters['ap'])) {
$sql .= " AND t.ap_program_id = ?";
$params[] = intval($filters['ap']);
}
$orderBy = $this->buildOrderBy($filters);
$sql .= " GROUP BY t.id {$orderBy}";
if ($limit > 0) {
$sql .= " LIMIT :limit OFFSET :offset";
}
$stmt = $this->pdo->prepare($sql);
// Bind named params only when LIMIT is used (mix of ? and : is not allowed).
if ($limit > 0) {
// Re-bind all positional params by index.
foreach ($params as $i => $val) {
$stmt->bindValue($i + 1, $val);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
} else {
$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, bool $showContact = false) {
$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 = ?, show_contact = ? WHERE id = ?");
$updateStmt->execute([$email, $showContact ? 1 : 0, $author['id']]);
} else {
$updateStmt = $this->pdo->prepare("UPDATE authors SET show_contact = ? WHERE id = ?");
$updateStmt->execute([$showContact ? 1 : 0, $author['id']]);
}
return $author['id'];
}
$stmt = $this->pdo->prepare("INSERT INTO authors (name, email, show_contact) VALUES (?, ?, ?)");
$stmt->execute([$name, $email, $showContact ? 1 : 0]);
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);
}
/**
* Set the published state of a single thesis.
*/
public function setPublished(int $thesisId, bool $published): void {
$this->pdo->prepare(
'UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
)->execute([$published ? 1 : 0, $thesisId]);
}
/**
* Set the published state for multiple theses at once.
* @param int[] $thesisIds
*/
public function bulkSetPublished(array $thesisIds, bool $published): void {
if (empty($thesisIds)) return;
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
$params = array_merge([$published ? 1 : 0], $thesisIds);
$this->pdo->prepare(
"UPDATE theses SET is_published = ?, 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();
}
// ========================================================================
// SITE SETTINGS
// ========================================================================
/**
* Get a single site setting value by key. Returns $default if not found.
*/
public function getSetting(string $key, string $default = ''): string {
$stmt = $this->pdo->prepare("SELECT value FROM site_settings WHERE key = ? LIMIT 1");
$stmt->execute([$key]);
$row = $stmt->fetch();
return $row ? (string) $row['value'] : $default;
}
/**
* Get all site settings as an associative array [ key => value ].
*/
public function getAllSettings(): array {
$stmt = $this->pdo->query("SELECT key, value FROM site_settings");
$rows = $stmt->fetchAll();
$out = [];
foreach ($rows as $r) {
$out[$r['key']] = $r['value'];
}
return $out;
}
/**
* Upsert a site setting.
*/
public function setSetting(string $key, string $value): void {
$this->pdo->prepare(
"INSERT INTO site_settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP"
)->execute([$key, $value]);
}
/**
* Return access types that are enabled in the add-thesis form,
* filtered by site_settings toggles.
* 'Libre' (id=1) is excluded unless access_type_libre_enabled = '1'.
* 'Interne' (id=2) is excluded unless access_type_interne_enabled = '1'.
* 'Interdit' (id=3) is excluded unless access_type_interdit_enabled = '1'.
*/
public function getEnabledFormAccessTypes(): array {
$settings = $this->getAllSettings();
$all = $this->getAccessTypes();
$map = [
'Libre' => $settings['access_type_libre_enabled'] ?? '0',
'Interne' => $settings['access_type_interne_enabled'] ?? '1',
'Interdit' => $settings['access_type_interdit_enabled'] ?? '1',
];
return array_values(array_filter($all, fn($at) => ($map[$at['name']] ?? '0') === '1'));
}
/**
* Update the show_contact flag for the first author of a thesis.
*/
public function setAuthorShowContact(int $thesisId, bool $show): void {
$stmt = $this->pdo->prepare(
"UPDATE authors SET show_contact = ?
WHERE id = (
SELECT author_id FROM thesis_authors
WHERE thesis_id = ?
ORDER BY author_order LIMIT 1
)"
);
$stmt->execute([$show ? 1 : 0, $thesisId]);
}
// ========================================================================
// 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]);
}
}
}
/**
* Return the list of language IDs currently linked to a thesis.
* @return int[]
*/
public function getThesisLanguageIds(int $thesisId): array {
$stmt = $this->pdo->prepare(
"SELECT language_id FROM thesis_languages WHERE thesis_id = ?"
);
$stmt->execute([$thesisId]);
return $stmt->fetchAll(PDO::FETCH_COLUMN);
}
/**
* Return the list of format IDs currently linked to a thesis.
* @return int[]
*/
public function getThesisFormatIds(int $thesisId): array {
$stmt = $this->pdo->prepare(
"SELECT format_id FROM thesis_formats WHERE thesis_id = ?"
);
$stmt->execute([$thesisId]);
return $stmt->fetchAll(PDO::FETCH_COLUMN);
}
/**
* 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<int,string>
*/
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.
*/
/**
* Update core thesis fields.
* All columns except identifier, submitted_at, and file-related fields.
*/
public function updateThesis(int $thesisId, array $data): void {
$stmt = $this->pdo->prepare("
UPDATE theses SET
title = ?,
subtitle = ?,
year = ?,
orientation_id = ?,
ap_program_id = ?,
finality_id = ?,
synopsis = ?,
context_note = ?,
file_size_info = ?,
baiu_link = ?,
license_id = ?,
access_type_id = ?,
is_published = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
");
$stmt->execute([
$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['context_note']) ? $data['context_note'] : null,
!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,
isset($data['access_type_id']) ? $data['access_type_id'] : null,
$data['is_published'] ? 1 : 0,
$thesisId,
]);
}
/**
* Replace all author associations for a thesis (delete-then-reinsert).
* $authors is an array of ['name' => string, 'email' => string|null].
* The first entry is considered the primary author (author_order = 1).
*/
public function setThesisAuthors(int $thesisId, array $authors): void {
$this->pdo->prepare("DELETE FROM thesis_authors WHERE thesis_id = ?")->execute([$thesisId]);
$stmt = $this->pdo->prepare(
"INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, ?)"
);
foreach ($authors as $index => $author) {
$name = trim($author['name'] ?? '');
if ($name === '') continue;
$showContact = !empty($author['show_contact']);
$authorId = $this->findOrCreateAuthor($name, $author['email'] ?? null, $showContact);
$stmt->execute([$thesisId, $authorId, $index + 1]);
}
}
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,
access_type_id,
is_published,
submitted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 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,
isset($data['access_type_id']) ? (int)$data['access_type_id'] : 2, // default: Interne
]);
$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;
}
/**
* Delete a single thesis and all its related data (cascade via FK).
* Also removes the banner file from disk if present.
*/
public function deleteThesis(int $thesisId): void {
// Clean up banner file
$bannerPath = $this->getThesisBannerPath($thesisId);
if ($bannerPath !== null) {
$fullPath = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/' . $bannerPath : null;
if ($fullPath && file_exists($fullPath)) {
@unlink($fullPath);
}
}
// Clean up thesis files from disk
$files = $this->getThesisFiles($thesisId);
foreach ($files as $file) {
if (!empty($file['file_path']) && file_exists($file['file_path'])) {
@unlink($file['file_path']);
}
}
// DB cascade handles junction tables
$this->pdo->prepare("DELETE FROM theses WHERE id = ?")->execute([$thesisId]);
}
/**
* Delete multiple theses at once.
* @param int[] $thesisIds
*/
public function bulkDeleteTheses(array $thesisIds): void {
if (empty($thesisIds)) return;
// Clean up files for each thesis
foreach ($thesisIds as $id) {
$bannerPath = $this->getThesisBannerPath($id);
if ($bannerPath !== null) {
$fullPath = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/' . $bannerPath : null;
if ($fullPath && file_exists($fullPath)) {
@unlink($fullPath);
}
}
$files = $this->getThesisFiles($id);
foreach ($files as $file) {
if (!empty($file['file_path']) && file_exists($file['file_path'])) {
@unlink($file['file_path']);
}
}
}
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
$this->pdo->prepare("DELETE FROM theses WHERE id IN ($placeholders)")->execute($thesisIds);
}
/**
* 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'];
}
/**
* Delete every thesis in the database.
*/
public function deleteAllTheses(): int {
$ids = $this->pdo->query("SELECT id FROM theses")->fetchAll(\PDO::FETCH_COLUMN);
if (empty($ids)) return 0;
$count = count($ids);
$this->bulkDeleteTheses($ids);
return $count;
}
/**
* 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.
* PHP 8.x deprecates throwing from __wakeup(); use trigger_error instead.
*/
public function __wakeup(): void {
// phpcs:ignore
trigger_error('Cannot unserialize singleton ' . static::class, E_USER_ERROR);
}
}