mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
1. note_intention: Delete old file only when a genuinely new upload arrives
(32-char hex file_id), not when the FilePond pool preserves an existing
file by sending its DB integer ID. Previously the DB integer ID
triggered $hasNewNote=true, which deleted the existing note_intention
from disk+DB, then handleFilePondSingleFile couldn't re-process it
because the regex requires a hex pattern. Same fix applied to cover.
2. All file deletions now use deleteThesisFileToTrash() which renames
files to tmp/_trash/ instead of unlinking. The trash preserves
original filenames prefixed with DB id for traceability. Skips
website URLs and PeerTube refs (no disk file).
3. Storage prefix changed from theses/ to documents/ to reflect that
the folder holds all document types (determined by file_type in DB).
MediaController visibility gate supports both prefixes for backward
compat with existing files.
4. File browser + relink feature for orphaned files:
- /admin/fragments/file-browser.php — HTMX tree browser for
storage/documents/ and storage/theses/
- /admin/actions/filepond/relink.php — POST endpoint that inserts
a thesis_files row pointing to existing on-disk file
- Per-pool "📂 Relier" buttons (edit mode only)
- JS: XamxamOpenFileBrowser / XamxamRelinkFile with FilePond integration
- CSS: .relink-modal dialog + .file-browser tree styles
3206 lines
115 KiB
PHP
3206 lines
115 KiB
PHP
<?php
|
||
|
||
/**
|
||
* Unified Database connection class for XAMXAM 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');
|
||
$this->runMigrations();
|
||
} catch (PDOException $e) {
|
||
error_log('Database connection failed: ' . $e->getMessage());
|
||
throw new Exception('Impossible de se connecter à la base de données.');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Run one-off schema migrations.
|
||
*/
|
||
private function runMigrations(): void
|
||
{
|
||
// Add 'name' column to share_links if missing
|
||
try {
|
||
$this->pdo->exec("ALTER TABLE share_links ADD COLUMN name TEXT");
|
||
} catch (\PDOException $e) {
|
||
// Column already exists — ignore
|
||
}
|
||
// Add 'locked_year' column to share_links if missing
|
||
try {
|
||
$this->pdo->exec("ALTER TABLE share_links ADD COLUMN locked_year INTEGER");
|
||
} catch (\PDOException $e) {
|
||
// Column already exists — ignore
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Determine database path.
|
||
* Priority: explicit override → APP_ROOT /storage/xamxam.db.
|
||
* APP_ROOT is defined by bootstrap.php before any controller loads Database.
|
||
*/
|
||
private function determineDatabasePath($customPath = null): string
|
||
{
|
||
if ($customPath !== null && file_exists($customPath)) {
|
||
return $customPath;
|
||
}
|
||
|
||
$root = defined('APP_ROOT') ? APP_ROOT : __DIR__ . '/..';
|
||
return $root . '/storage/xamxam.db';
|
||
}
|
||
|
||
/**
|
||
* Fetch a single row by ID from a table. Returns null if not found.
|
||
*/
|
||
private function fetchRow(string $table, int $id): ?array
|
||
{
|
||
$stmt = $this->pdo->prepare("SELECT * FROM $table WHERE id = ?");
|
||
$stmt->execute([$id]);
|
||
$row = $stmt->fetch();
|
||
return $row !== false ? $row : null;
|
||
}
|
||
|
||
/**
|
||
* 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 AND deleted_at IS NULL)
|
||
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 AND deleted_at IS NULL');
|
||
$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 AND deleted_at IS NULL';
|
||
$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, ordered by sort_order then upload time.
|
||
* Covers the new sort_order column added in migration 007.
|
||
*/
|
||
public function getThesisFiles($thesisId)
|
||
{
|
||
$sql = 'SELECT * FROM thesis_files WHERE thesis_id = :thesis_id ORDER BY sort_order ASC, uploaded_at ASC';
|
||
$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 AND t.deleted_at IS NULL
|
||
GROUP BY t.id
|
||
ORDER BY MIN(a.name) ASC'
|
||
);
|
||
return $stmt->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* Fetch all published theses for a given author name.
|
||
* Returns rows of [id => int, title => string].
|
||
*/
|
||
public function getThesesByAuthorName(string $name): array
|
||
{
|
||
$stmt = $this->pdo->prepare(
|
||
'SELECT vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis,
|
||
vp.orientation, vp.finality_type, vp.authors
|
||
FROM v_theses_public vp
|
||
JOIN thesis_authors ta ON ta.thesis_id = vp.id
|
||
JOIN authors a ON a.id = ta.author_id
|
||
WHERE a.name = ?
|
||
ORDER BY vp.year DESC, vp.title ASC'
|
||
);
|
||
$stmt->execute([$name]);
|
||
return $stmt->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* Batch variant: fetch preview data for a list of author names in one query.
|
||
* Returns [ authorName => [ thesis, ... ], ... ]
|
||
*
|
||
* @param string[] $names
|
||
* @return array<string, array>
|
||
*/
|
||
public function getThesesForAuthors(array $names): array
|
||
{
|
||
if (empty($names)) {
|
||
return [];
|
||
}
|
||
|
||
$placeholders = implode(',', array_fill(0, count($names), '?'));
|
||
$stmt = $this->pdo->prepare(
|
||
"SELECT a.name AS author_name,
|
||
vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis,
|
||
vp.orientation, vp.finality_type, vp.authors
|
||
FROM v_theses_public vp
|
||
JOIN thesis_authors ta ON ta.thesis_id = vp.id
|
||
JOIN authors a ON a.id = ta.author_id
|
||
WHERE a.name IN ($placeholders)
|
||
ORDER BY a.name ASC, vp.year DESC, vp.title ASC"
|
||
);
|
||
$stmt->execute($names);
|
||
$rows = $stmt->fetchAll();
|
||
|
||
$grouped = [];
|
||
foreach ($rows as $row) {
|
||
$grouped[$row['author_name']][] = $row;
|
||
}
|
||
return $grouped;
|
||
}
|
||
|
||
public function getAvailableYears()
|
||
{
|
||
$sql = 'SELECT DISTINCT year FROM theses WHERE is_published = 1 AND deleted_at IS NULL 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 AND th.deleted_at IS NULL AND tg.deleted_at IS NULL
|
||
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', 't.deleted_at IS NULL'];
|
||
$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 — single-valued FK: use full intersection (including own filter).
|
||
// Clicking one year should fade years that have zero theses in the current result.
|
||
$matchedYearsIds = array_column($exec("SELECT DISTINCT t.year $baseJoins WHERE $wAll", $bAll), 'year');
|
||
$allYears = array_column($exec('SELECT DISTINCT year FROM theses WHERE is_published=1 AND deleted_at IS NULL ORDER BY year DESC', []), 'year');
|
||
$yearsOut = array_map(fn ($y) => ['value' => $y, 'matched' => in_array($y, $matchedYearsIds, true)], $allYears);
|
||
|
||
// AP programs — single-valued FK: use full intersection.
|
||
$matchedApIds = array_column($exec("SELECT DISTINCT ap.name $baseJoins WHERE $wAll AND ap.name IS NOT NULL", $bAll), '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, $matchedApIds, true)], $allAp);
|
||
|
||
// Orientations — single-valued FK: use full intersection.
|
||
$matchedOrIds = array_column($exec("SELECT DISTINCT o.name $baseJoins WHERE $wAll AND o.name IS NOT NULL", $bAll), 'name');
|
||
$allOr = array_column($exec('SELECT name FROM orientations ORDER BY name', []), 'name');
|
||
$orOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedOrIds, true)], $allOr);
|
||
|
||
// Finality types — single-valued FK: use full intersection.
|
||
$matchedFiIds = array_column($exec("SELECT DISTINCT ft.name $baseJoins WHERE $wAll AND ft.name IS NOT NULL", $bAll), '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, $matchedFiIds, 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 AND th.deleted_at IS NULL AND tg.deleted_at IS NULL 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 sort_order, id');
|
||
return $stmt->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* Get all languages (name is capitalized for display).
|
||
*/
|
||
public function getAllLanguages(): array
|
||
{
|
||
$stmt = $this->pdo->query("SELECT id, UPPER(SUBSTR(name,1,1)) || SUBSTR(name,2) as name, created_at FROM languages WHERE deleted_at IS NULL ORDER BY name");
|
||
return $stmt->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* Return only the predefined / hardcoded languages used as checkboxes
|
||
* in the form. All other languages go into the "Autre langue" input.
|
||
*
|
||
* De-duplicates accent variants (e.g. 'francais' + 'français') by
|
||
* returning the accented row. No REGEXP in SQLite, so we use a
|
||
* priority-window approach.
|
||
*/
|
||
public function getPredefinedLanguages(): array
|
||
{
|
||
$langs = $this->pdo->query(
|
||
"SELECT id, name, created_at,
|
||
CASE
|
||
WHEN LOWER(name) IN ('français', 'francais') THEN 1
|
||
WHEN LOWER(name) = 'anglais' THEN 2
|
||
WHEN LOWER(name) IN ('néerlandais', 'neerlandais') THEN 3
|
||
END AS grp,
|
||
CASE WHEN name GLOB '*[À-ÿ]*' THEN 0 ELSE 1 END AS is_ascii
|
||
FROM languages
|
||
WHERE LOWER(name) IN ('français', 'anglais', 'néerlandais', 'francais', 'neerlandais')
|
||
AND deleted_at IS NULL
|
||
ORDER BY grp, is_ascii"
|
||
)->fetchAll();
|
||
|
||
// De-duplicate: keep first row per grp (accented variant wins due to ORDER BY)
|
||
$seen = [];
|
||
$dedup = [];
|
||
foreach ($langs as $l) {
|
||
$g = $l['grp'];
|
||
if (isset($seen[$g])) continue;
|
||
$seen[$g] = true;
|
||
$dedup[] = [
|
||
'id' => $l['id'],
|
||
'name' => $l['name'],
|
||
'created_at' => $l['created_at'],
|
||
];
|
||
}
|
||
return $dedup;
|
||
}
|
||
|
||
// ========================================================================
|
||
// 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',
|
||
'authors' => 'authors',
|
||
'year' => 't.year',
|
||
'orientation' => 'o.name',
|
||
'ap_program' => 'ap.code',
|
||
'is_published' => 't.is_published',
|
||
'access_type' => 'at.name',
|
||
'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 t.deleted_at IS NULL';
|
||
|
||
$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.code as ap_program,
|
||
GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) 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 t.deleted_at IS NULL';
|
||
|
||
$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 WHERE deleted_at IS NULL 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
|
||
WHERE deleted_at IS NULL'
|
||
);
|
||
$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)
|
||
{
|
||
// Normalise CSV artefacts: OUI/NON strings in email column → null
|
||
if ($email !== null && in_array(strtoupper(trim($email)), ['NON', 'OUI'], true)) {
|
||
$email = null;
|
||
}
|
||
|
||
$cleanEmail = ($email !== null && $email !== '') ? $email : null;
|
||
|
||
// Try to find by name first
|
||
$stmt = $this->pdo->prepare('SELECT id, email FROM authors WHERE name = ?');
|
||
$stmt->execute([$name]);
|
||
$author = $stmt->fetch();
|
||
|
||
if ($author) {
|
||
// Update email and show_contact unless that email belongs to another author.
|
||
if ($cleanEmail !== null) {
|
||
$dup = $this->pdo->prepare('SELECT id FROM authors WHERE email = ? AND id != ?');
|
||
$dup->execute([$cleanEmail, $author['id']]);
|
||
if ($dup->fetch()) {
|
||
$cleanEmail = null; // don't steal another author's email
|
||
}
|
||
}
|
||
$updateStmt = $this->pdo->prepare('UPDATE authors SET email = ?, show_contact = ? WHERE id = ?');
|
||
$updateStmt->execute([$cleanEmail, $showContact ? 1 : 0, $author['id']]);
|
||
return $author['id'];
|
||
}
|
||
|
||
// If an author with this email already exists (different name), reuse that record.
|
||
if ($cleanEmail !== null) {
|
||
$byEmail = $this->pdo->prepare('SELECT id FROM authors WHERE email = ?');
|
||
$byEmail->execute([$cleanEmail]);
|
||
$existing = $byEmail->fetch();
|
||
if ($existing) {
|
||
return $existing['id'];
|
||
}
|
||
}
|
||
|
||
$stmt = $this->pdo->prepare('INSERT INTO authors (name, email, show_contact) VALUES (?, ?, ?)');
|
||
$stmt->execute([$name, $cleanEmail, $showContact ? 1 : 0]);
|
||
return $this->pdo->lastInsertId();
|
||
}
|
||
|
||
/**
|
||
* Check whether an existing thesis is a likely duplicate of the data being
|
||
* submitted.
|
||
*
|
||
* Matching logic (all three conditions must be satisfied):
|
||
* - Same year
|
||
* - Same author name (case-insensitive, trimmed)
|
||
* - Normalised title similarity: after lowercasing and stripping all
|
||
* non-alphanumeric characters, one title must start with the other or
|
||
* the Levenshtein distance must be ≤ 10 % of the longer string's length.
|
||
*
|
||
* Returns an associative array with keys
|
||
* [id, identifier, title, author, year]
|
||
* or null when no duplicate is found.
|
||
*
|
||
* @param string $title Proposed title.
|
||
* @param string $authorName Proposed author name.
|
||
* @param int $year Proposed year.
|
||
* @return array{id:int,identifier:string,title:string,author:string,year:int}|null
|
||
*/
|
||
/**
|
||
* @param string $title Proposed title.
|
||
* @param string[] $authorNames Proposed author names (already trimmed, non-empty).
|
||
* @param int $year Proposed year.
|
||
* @return array{id:int,identifier:string,title:string,author:string,year:int}|null
|
||
*/
|
||
public function findDuplicateThesis(string $title, array $authorNames, int $year): ?array
|
||
{
|
||
if (empty($authorNames)) {
|
||
return null;
|
||
}
|
||
|
||
// Fetch all theses for the same year that share any author with the submission.
|
||
$numAuthors = count($authorNames);
|
||
$ph = implode(',', array_fill(0, $numAuthors, 'LOWER(TRIM(?))'));
|
||
$params = array_merge([$year], $authorNames);
|
||
$stmt = $this->pdo->prepare(
|
||
"SELECT DISTINCT t.id, t.identifier, t.title, t.year,
|
||
GROUP_CONCAT(a2.name ORDER BY ta2.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
|
||
JOIN thesis_authors ta2 ON ta2.thesis_id = t.id
|
||
JOIN authors a2 ON a2.id = ta2.author_id
|
||
WHERE t.year = ?
|
||
AND LOWER(TRIM(a.name)) IN ({$ph})
|
||
GROUP BY t.id"
|
||
);
|
||
$stmt->execute($params);
|
||
$candidates = $stmt->fetchAll();
|
||
|
||
if (empty($candidates)) {
|
||
return null;
|
||
}
|
||
|
||
$normalise = static function (string $s): string {
|
||
return preg_replace('/[^a-z0-9]/u', '', strtolower($s));
|
||
};
|
||
|
||
$normNew = $normalise($title);
|
||
|
||
foreach ($candidates as $row) {
|
||
$normExisting = $normalise($row['title']);
|
||
|
||
// Exact match after normalisation.
|
||
if ($normNew === $normExisting) {
|
||
return [
|
||
'id' => (int)$row['id'],
|
||
'identifier' => $row['identifier'],
|
||
'title' => $row['title'],
|
||
'author' => $row['authors'],
|
||
'year' => (int)$row['year'],
|
||
];
|
||
}
|
||
|
||
// Prefix match: one starts with the other (handles subtitle variations).
|
||
$maxLen = max(strlen($normNew), strlen($normExisting));
|
||
if ($maxLen === 0) {
|
||
continue;
|
||
}
|
||
$minLen = min(strlen($normNew), strlen($normExisting));
|
||
if ($minLen >= 5) { // avoid matching very short fragments
|
||
if (str_starts_with($normExisting, $normNew) || str_starts_with($normNew, $normExisting)) {
|
||
return [
|
||
'id' => (int)$row['id'],
|
||
'identifier' => $row['identifier'],
|
||
'title' => $row['title'],
|
||
'author' => $row['authors'],
|
||
'year' => (int)$row['year'],
|
||
];
|
||
}
|
||
}
|
||
|
||
// Levenshtein distance ≤ 10 % of the longer string.
|
||
// levenshtein() is limited to 255 chars; use substrings for safety.
|
||
$a = substr($normNew, 0, 255);
|
||
$b = substr($normExisting, 0, 255);
|
||
$dist = levenshtein($a, $b);
|
||
$threshold = (int)ceil($maxLen * 0.10);
|
||
if ($dist <= $threshold) {
|
||
return [
|
||
'id' => (int)$row['id'],
|
||
'identifier' => $row['identifier'],
|
||
'title' => $row['title'],
|
||
'author' => $row['authors'],
|
||
'year' => (int)$row['year'],
|
||
];
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 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 = ? AND deleted_at IS NULL');
|
||
$stmt->execute([$name]);
|
||
$row = $stmt->fetch();
|
||
|
||
if ($row) {
|
||
return (int)$row['id'];
|
||
}
|
||
|
||
$stmt = $this->pdo->prepare('INSERT INTO tags (name) VALUES (?)');
|
||
$stmt->execute([$name]);
|
||
$newId = (int)$this->pdo->lastInsertId();
|
||
|
||
require_once __DIR__ . '/Audit.php';
|
||
Audit::log($this, Audit::actor(), 'INSERT', 'tags', $newId, null, ['id' => $newId, 'name' => $name]);
|
||
|
||
return $newId;
|
||
}
|
||
|
||
|
||
|
||
// ========================================================================
|
||
// TAG MANAGEMENT (admin)
|
||
// ========================================================================
|
||
|
||
/**
|
||
* Search supervisors by name prefix. Returns up to 10 matching supervisors.
|
||
* If $query is empty, returns the most-used ones (up to 10).
|
||
*
|
||
* @param string $role Optional role filter: 'promoteur_interne' (is_external=0, role=promoteur),
|
||
* 'promoteur_externe' (is_external=1, role=promoteur),
|
||
* 'lecteur_interne' (is_external=0, role=lecteur),
|
||
* 'lecteur_externe' (is_external=1, role=lecteur)
|
||
*/
|
||
public function searchSupervisors(string $query = '', string $role = ''): array
|
||
{
|
||
$query = trim($query);
|
||
|
||
// Map role to WHERE conditions
|
||
$roleWhere = '';
|
||
$roleParams = [];
|
||
switch ($role) {
|
||
case 'promoteur_interne':
|
||
$roleWhere = 'AND ts.is_external = 0 AND ts.role = \'promoteur\'';
|
||
break;
|
||
case 'promoteur_externe':
|
||
$roleWhere = 'AND ts.is_external = 1 AND ts.role = \'promoteur\'';
|
||
break;
|
||
case 'lecteur_interne':
|
||
$roleWhere = 'AND ts.is_external = 0 AND ts.role = \'lecteur\'';
|
||
break;
|
||
case 'lecteur_externe':
|
||
$roleWhere = 'AND ts.is_external = 1 AND ts.role = \'lecteur\'';
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
if ($query === '') {
|
||
$stmt = $this->pdo->query('
|
||
SELECT s.id, s.name,
|
||
COUNT(DISTINCT ts.thesis_id) as thesis_count
|
||
FROM supervisors s
|
||
LEFT JOIN thesis_supervisors ts ON s.id = ts.supervisor_id
|
||
' . $roleWhere . '
|
||
GROUP BY s.id
|
||
ORDER BY thesis_count DESC, s.name COLLATE NOCASE
|
||
LIMIT 10
|
||
');
|
||
} else {
|
||
$stmt = $this->pdo->prepare('
|
||
SELECT s.id, s.name,
|
||
COUNT(DISTINCT ts.thesis_id) as thesis_count
|
||
FROM supervisors s
|
||
LEFT JOIN thesis_supervisors ts ON s.id = ts.supervisor_id
|
||
WHERE s.name LIKE ?
|
||
' . $roleWhere . '
|
||
GROUP BY s.id
|
||
ORDER BY s.name = ? DESC, thesis_count DESC, s.name COLLATE NOCASE
|
||
LIMIT 10
|
||
');
|
||
$stmt->execute([$query . '%', $query]);
|
||
}
|
||
return $stmt->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* Search tags by name prefix. Returns up to 10 matching tags.
|
||
* If $query is empty, returns the most-used tags (up to 10).
|
||
*/
|
||
public function searchTags(string $query = ''): array
|
||
{
|
||
$query = trim($query);
|
||
if ($query === '') {
|
||
$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
|
||
WHERE tg.deleted_at IS NULL
|
||
GROUP BY tg.id
|
||
ORDER BY thesis_count DESC, tg.name COLLATE NOCASE
|
||
LIMIT 10
|
||
');
|
||
} else {
|
||
$stmt = $this->pdo->prepare('
|
||
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
|
||
WHERE tg.name LIKE ? AND tg.deleted_at IS NULL
|
||
GROUP BY tg.id
|
||
ORDER BY tg.name = ? DESC, thesis_count DESC, tg.name COLLATE NOCASE
|
||
LIMIT 10
|
||
');
|
||
$stmt->execute([$query . '%', $query]);
|
||
}
|
||
return $stmt->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
WHERE tg.deleted_at IS NULL
|
||
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 (excluding soft-deleted rows)
|
||
$stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ? AND id != ? AND deleted_at IS NULL');
|
||
$stmt->execute([$newName, $id]);
|
||
if ($stmt->fetch()) {
|
||
throw new Exception('Un tag avec ce nom existe déjà.');
|
||
}
|
||
require_once __DIR__ . '/Audit.php';
|
||
$old = $this->fetchRow('tags', $id);
|
||
$this->pdo->prepare('UPDATE tags SET name = ? WHERE id = ?')->execute([$newName, $id]);
|
||
$new = $this->fetchRow('tags', $id);
|
||
Audit::log($this, Audit::actor(), 'UPDATE', 'tags', $id, $old, $new);
|
||
}
|
||
|
||
/**
|
||
* 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]);
|
||
// Soft-delete the source tag itself
|
||
require_once __DIR__ . '/Audit.php';
|
||
$old = $this->fetchRow('tags', $sourceId);
|
||
$this->pdo->prepare("UPDATE tags SET deleted_at = datetime('now') WHERE id = ?")->execute([$sourceId]);
|
||
Audit::log($this, Audit::actor(), 'DELETE', 'tags', $sourceId, $old);
|
||
$this->pdo->commit();
|
||
} catch (\Throwable $e) {
|
||
$this->pdo->rollBack();
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Soft-delete a tag (sets deleted_at).
|
||
*/
|
||
public function deleteTag(int $id): void
|
||
{
|
||
require_once __DIR__ . '/Audit.php';
|
||
$old = $this->fetchRow('tags', $id);
|
||
$this->pdo->prepare("UPDATE tags SET deleted_at = datetime('now') WHERE id = ?")->execute([$id]);
|
||
Audit::log($this, Audit::actor(), 'DELETE', 'tags', $id, $old);
|
||
}
|
||
|
||
// ========================================================================
|
||
// LANGUAGE MANAGEMENT (admin)
|
||
// ========================================================================
|
||
|
||
/**
|
||
* Search languages by name prefix. Returns up to 10 matching languages.
|
||
* If $query is empty, returns the most-used languages (up to 10).
|
||
*/
|
||
public function searchLanguages(string $query = ''): array
|
||
{
|
||
$query = trim($query);
|
||
if ($query === '') {
|
||
$stmt = $this->pdo->query('
|
||
SELECT MIN(l.id) as id,
|
||
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
|
||
COUNT(DISTINCT tl.thesis_id) as thesis_count
|
||
FROM languages l
|
||
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
||
WHERE l.deleted_at IS NULL
|
||
GROUP BY LOWER(l.name)
|
||
ORDER BY thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
|
||
LIMIT 10
|
||
');
|
||
} else {
|
||
$stmt = $this->pdo->prepare('
|
||
SELECT MIN(l.id) as id,
|
||
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
|
||
COUNT(DISTINCT tl.thesis_id) as thesis_count
|
||
FROM languages l
|
||
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
||
WHERE LOWER(l.name) LIKE LOWER(?) AND l.deleted_at IS NULL
|
||
GROUP BY LOWER(l.name)
|
||
ORDER BY LOWER(MIN(l.name)) = LOWER(?) DESC, thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
|
||
LIMIT 10
|
||
');
|
||
$stmt->execute([$query . '%', $query]);
|
||
}
|
||
return $stmt->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* Return all languages with a count of associated theses.
|
||
*/
|
||
public function getAllLanguagesWithCount(): array
|
||
{
|
||
// Group by lowercased name to deduplicate, keeping the first id
|
||
$stmt = $this->pdo->query('
|
||
SELECT MIN(l.id) as id,
|
||
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
|
||
COUNT(DISTINCT tl.thesis_id) as thesis_count
|
||
FROM languages l
|
||
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
||
WHERE l.deleted_at IS NULL
|
||
GROUP BY LOWER(l.name)
|
||
ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE
|
||
');
|
||
return $stmt->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* Deduplicate languages: merge languages with the same lowercased name.
|
||
*/
|
||
public function deduplicateLanguages(): void
|
||
{
|
||
$dupes = $this->pdo->query('
|
||
SELECT LOWER(name) as lname, MIN(id) as keep_id
|
||
FROM languages
|
||
WHERE deleted_at IS NULL
|
||
GROUP BY LOWER(name)
|
||
HAVING COUNT(*) > 1
|
||
')->fetchAll();
|
||
|
||
foreach ($dupes as $dup) {
|
||
$this->pdo->prepare('
|
||
INSERT OR IGNORE INTO thesis_languages (language_id, thesis_id)
|
||
SELECT ?, thesis_id FROM thesis_languages WHERE language_id IN (
|
||
SELECT id FROM languages WHERE LOWER(name) = ? AND id != ?
|
||
)
|
||
')->execute([$dup['keep_id'], $dup['lname'], $dup['keep_id']]);
|
||
|
||
$this->pdo->prepare('
|
||
DELETE FROM thesis_languages WHERE language_id IN (
|
||
SELECT id FROM languages WHERE LOWER(name) = ? AND id != ?
|
||
)
|
||
')->execute([$dup['lname'], $dup['keep_id']]);
|
||
|
||
$this->pdo->prepare(
|
||
"UPDATE languages SET deleted_at = datetime('now') WHERE LOWER(name) = ? AND id != ?"
|
||
)->execute([$dup['lname'], $dup['keep_id']]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Rename a language. Throws if the new name already exists.
|
||
*/
|
||
public function renameLanguage(int $id, string $newName): void
|
||
{
|
||
$newName = trim($newName);
|
||
if ($newName === '') {
|
||
throw new Exception('Le nom de la langue ne peut pas être vide.');
|
||
}
|
||
$stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND id != ? AND deleted_at IS NULL');
|
||
$stmt->execute([$newName, $id]);
|
||
if ($stmt->fetch()) {
|
||
throw new Exception('Une langue avec ce nom existe déjà.');
|
||
}
|
||
require_once __DIR__ . '/Audit.php';
|
||
$old = $this->fetchRow('languages', $id);
|
||
$this->pdo->prepare('UPDATE languages SET name = ? WHERE id = ?')->execute([$newName, $id]);
|
||
$new = $this->fetchRow('languages', $id);
|
||
Audit::log($this, Audit::actor(), 'UPDATE', 'languages', $id, $old, $new);
|
||
}
|
||
|
||
/**
|
||
* Merge sourceId into targetId: reassign all thesis_languages rows, then soft-delete source.
|
||
*/
|
||
public function mergeLanguage(int $sourceId, int $targetId): void
|
||
{
|
||
if ($sourceId === $targetId) {
|
||
throw new Exception('Source et destination identiques.');
|
||
}
|
||
$this->pdo->beginTransaction();
|
||
try {
|
||
$this->pdo->prepare('
|
||
INSERT OR IGNORE INTO thesis_languages (language_id, thesis_id)
|
||
SELECT ?, thesis_id FROM thesis_languages WHERE language_id = ?
|
||
')->execute([$targetId, $sourceId]);
|
||
$this->pdo->prepare('DELETE FROM thesis_languages WHERE language_id = ?')->execute([$sourceId]);
|
||
// Soft-delete the source language
|
||
require_once __DIR__ . '/Audit.php';
|
||
$old = $this->fetchRow('languages', $sourceId);
|
||
$this->pdo->prepare("UPDATE languages SET deleted_at = datetime('now') WHERE id = ?")->execute([$sourceId]);
|
||
Audit::log($this, Audit::actor(), 'DELETE', 'languages', $sourceId, $old);
|
||
$this->pdo->commit();
|
||
} catch (\Throwable $e) {
|
||
$this->pdo->rollBack();
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Soft-delete a language (sets deleted_at).
|
||
*/
|
||
public function deleteLanguage(int $id): void
|
||
{
|
||
require_once __DIR__ . '/Audit.php';
|
||
$old = $this->fetchRow('languages', $id);
|
||
$this->pdo->prepare("UPDATE languages SET deleted_at = datetime('now') WHERE id = ?")->execute([$id]);
|
||
Audit::log($this, Audit::actor(), 'DELETE', 'languages', $id, $old);
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
{
|
||
require_once __DIR__ . '/Audit.php';
|
||
$old = $this->fetchRow('theses', $thesisId);
|
||
$stmt = $this->pdo->prepare(
|
||
'UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||
);
|
||
$stmt->execute([$accessTypeId, $thesisId]);
|
||
$new = $this->fetchRow('theses', $thesisId);
|
||
Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, $old, $new);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
require_once __DIR__ . '/Audit.php';
|
||
$actor = Audit::actor();
|
||
$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);
|
||
foreach ($thesisIds as $id) {
|
||
$new = $this->fetchRow('theses', $id);
|
||
Audit::log($this, $actor, 'UPDATE', 'theses', $id, null, $new);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set the published state of a single thesis.
|
||
*/
|
||
public function setPublished(int $thesisId, bool $published): void
|
||
{
|
||
require_once __DIR__ . '/Audit.php';
|
||
$old = $this->fetchRow('theses', $thesisId);
|
||
$this->pdo->prepare(
|
||
'UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||
)->execute([$published ? 1 : 0, $thesisId]);
|
||
$new = $this->fetchRow('theses', $thesisId);
|
||
Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, $old, $new);
|
||
}
|
||
|
||
/**
|
||
* Set the published state for multiple theses at once.
|
||
* @param int[] $thesisIds
|
||
*/
|
||
public function bulkSetPublished(array $thesisIds, bool $published): void
|
||
{
|
||
if (empty($thesisIds)) {
|
||
return;
|
||
}
|
||
require_once __DIR__ . '/Audit.php';
|
||
$actor = Audit::actor();
|
||
$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);
|
||
foreach ($thesisIds as $id) {
|
||
$new = $this->fetchRow('theses', $id);
|
||
Audit::log($this, $actor, 'UPDATE', 'theses', $id, null, $new);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.is_ulb, 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, is_ulb, 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;
|
||
$isUlb = isset($member['is_ulb']) ? (int)$member['is_ulb'] : 0;
|
||
$stmt->execute([$thesisId, $supervisorId, $role, $isExternal, $isUlb, (int)$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]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Return the ID of an existing language by name, inserting it if absent.
|
||
* Name is stored lowercase and displayed with first letter capitalized.
|
||
*/
|
||
/**
|
||
* Find or create a language by name (case-insensitive, accent-tolerant).
|
||
*
|
||
* Normalises the name to lowercase. Before creating a new row, checks
|
||
* whether the name differs from an existing row only by accents (e.g.
|
||
* 'francais' → matches existing 'français') and returns the existing ID.
|
||
*/
|
||
public function getOrCreateLanguage(string $name): int
|
||
{
|
||
$name = strtolower(trim($name));
|
||
if ($name === '') {
|
||
throw new \InvalidArgumentException('Language name must not be empty.');
|
||
}
|
||
|
||
// 1. Exact lowercase match (skip soft-deleted rows)
|
||
$stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND deleted_at IS NULL LIMIT 1');
|
||
$stmt->execute([$name]);
|
||
$id = $stmt->fetchColumn();
|
||
if ($id !== false) {
|
||
return (int)$id;
|
||
}
|
||
|
||
// 2. Accent-tolerant fallback: strip accents and re-compare.
|
||
$asciiName = self::stripAccents($name);
|
||
if ($asciiName !== $name) {
|
||
$all = $this->pdo->query('SELECT id, name FROM languages WHERE deleted_at IS NULL')->fetchAll();
|
||
foreach ($all as $row) {
|
||
if (self::stripAccents(strtolower($row['name'])) === $asciiName) {
|
||
return (int)$row['id'];
|
||
}
|
||
}
|
||
}
|
||
|
||
$this->pdo->prepare('INSERT INTO languages (name) VALUES (?)')->execute([$name]);
|
||
$newId = (int)$this->pdo->lastInsertId();
|
||
|
||
require_once __DIR__ . '/Audit.php';
|
||
Audit::log($this, Audit::actor(), 'INSERT', 'languages', $newId, null, ['id' => $newId, 'name' => $name]);
|
||
|
||
return $newId;
|
||
}
|
||
|
||
/**
|
||
* 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++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// COVER METHODS (formerly also BANNER METHODS — banners merged into covers)
|
||
// ========================================================================
|
||
|
||
// ========================================================================
|
||
// 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, license_custom, access_type_id, context_note, remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc2r FROM theses WHERE id = ? LIMIT 1'
|
||
);
|
||
$stmt->execute([$thesisId]);
|
||
$row = $stmt->fetch();
|
||
return $row !== false ? $row : 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 documents/ or 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.
|
||
*/
|
||
/**
|
||
* Generate a unique identifier like "2025-003" for a new thesis.
|
||
*
|
||
* Uses the actual maximum sequence number for the given year (from
|
||
* existing identifiers) rather than the row count, so deletes don't
|
||
* cause identifier collisions.
|
||
*
|
||
* Must be called inside an open transaction.
|
||
*/
|
||
public function generateThesisIdentifier(int $year): string
|
||
{
|
||
$stmt = $this->pdo->prepare(
|
||
'SELECT COALESCE(MAX(CAST(SUBSTR(identifier, 6) AS INTEGER)), 0) FROM theses WHERE identifier LIKE ?'
|
||
);
|
||
$stmt->execute([$year . '-%']);
|
||
$maxSeq = (int)$stmt->fetchColumn();
|
||
return sprintf('%d-%03d', $year, $maxSeq + 1);
|
||
}
|
||
|
||
/**
|
||
* 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),
|
||
* 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
|
||
{
|
||
require_once __DIR__ . '/Audit.php';
|
||
Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId,
|
||
$this->fetchRow('theses', $thesisId)
|
||
);
|
||
|
||
$stmt = $this->pdo->prepare('
|
||
UPDATE theses SET
|
||
title = ?,
|
||
subtitle = ?,
|
||
year = ?,
|
||
orientation_id = ?,
|
||
ap_program_id = ?,
|
||
finality_id = ?,
|
||
synopsis = ?,
|
||
context_note = ?,
|
||
baiu_link = ?,
|
||
license_id = ?,
|
||
license_custom = ?,
|
||
access_type_id = ?,
|
||
is_published = ?,
|
||
remarks = ?,
|
||
jury_points = ?,
|
||
exemplaire_baiu = ?,
|
||
exemplaire_erg = ?,
|
||
cc2r = ?,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?
|
||
');
|
||
$orientation = ($data['orientation_id'] ?? null) ? (int)$data['orientation_id'] : null;
|
||
$ap = ($data['ap_program_id'] ?? null) ? (int)$data['ap_program_id'] : null;
|
||
$finality = ($data['finality_id'] ?? null) ? (int)$data['finality_id'] : null;
|
||
$license = $data['license_id'] ?? null;
|
||
$access = $data['access_type_id'] ?? null;
|
||
|
||
error_log("[DB:updateThesis] thesis_id=$thesisId orientation=$orientation ap=$ap finality=$finality license=$license access=$access");
|
||
|
||
$stmt->execute([
|
||
$data['title'],
|
||
!empty($data['subtitle']) ? $data['subtitle'] : null,
|
||
(int)$data['year'],
|
||
$orientation,
|
||
$ap,
|
||
$finality,
|
||
$data['synopsis'],
|
||
!empty($data['context_note']) ? $data['context_note'] : null,
|
||
!empty($data['baiu_link']) ? $data['baiu_link'] : null,
|
||
$license,
|
||
!empty($data['license_custom']) ? $data['license_custom'] : null,
|
||
$access,
|
||
$data['is_published'] ? 1 : 0,
|
||
!empty($data['remarks']) ? $data['remarks'] : null,
|
||
isset($data['jury_points']) && $data['jury_points'] !== '' ? (float)$data['jury_points'] : null,
|
||
!empty($data['exemplaire_baiu']) ? 1 : 0,
|
||
!empty($data['exemplaire_erg']) ? 1 : 0,
|
||
!empty($data['cc2r']) ? 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, (int)$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, context_note,
|
||
baiu_link, license_id, license_custom,
|
||
access_type_id,
|
||
objet,
|
||
is_published,
|
||
remarks, jury_points,
|
||
exemplaire_baiu, exemplaire_erg,
|
||
cc2r,
|
||
submitted_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||
');
|
||
|
||
$validObjet = ['tfe', 'thèse', 'frart'];
|
||
$objet = in_array($data['objet'] ?? '', $validObjet, true) ? $data['objet'] : 'tfe';
|
||
|
||
$orientation = $data['orientation_id'] ?? null;
|
||
$ap = $data['ap_program_id'] ?? null;
|
||
$finality = $data['finality_id'] ?? null;
|
||
$license = $data['license_id'] ?? null;
|
||
$access = $data['access_type_id'] ?? 2;
|
||
|
||
$stmt->execute([
|
||
$identifier,
|
||
$data['title'],
|
||
!empty($data['subtitle']) ? $data['subtitle'] : null,
|
||
(int)$data['year'],
|
||
$orientation ? (int)$orientation : null,
|
||
$ap ? (int)$ap : null,
|
||
$finality ? (int)$finality : null,
|
||
$data['synopsis'],
|
||
!empty($data['context_note']) ? $data['context_note'] : null,
|
||
!empty($data['baiu_link']) ? $data['baiu_link'] : null,
|
||
$license ? (int)$license : null,
|
||
!empty($data['license_custom']) ? $data['license_custom'] : null,
|
||
$access ? (int)$access : 2, // default: Interne
|
||
$objet,
|
||
!empty($data['remarks']) ? $data['remarks'] : null,
|
||
isset($data['jury_points']) && $data['jury_points'] !== '' ? (float)$data['jury_points'] : null,
|
||
!empty($data['exemplaire_baiu']) ? 1 : 0,
|
||
!empty($data['exemplaire_erg']) ? 1 : 0,
|
||
!empty($data['cc2r']) ? 1 : 0,
|
||
]);
|
||
|
||
$newId = (int)$this->pdo->lastInsertId();
|
||
|
||
require_once __DIR__ . '/Audit.php';
|
||
$new = $this->fetchRow('theses', $newId);
|
||
Audit::log($this, Audit::actor(), 'INSERT', 'theses', $newId, null, $new);
|
||
|
||
return $newId;
|
||
}
|
||
|
||
/**
|
||
* Soft-delete a single thesis (sets deleted_at).
|
||
*/
|
||
public function deleteThesis(int $thesisId): void
|
||
{
|
||
require_once __DIR__ . '/Audit.php';
|
||
$old = $this->fetchRow('theses', $thesisId);
|
||
$this->pdo->prepare("UPDATE theses SET deleted_at = datetime('now') WHERE id = ?")->execute([$thesisId]);
|
||
Audit::log($this, Audit::actor(), 'DELETE', 'theses', $thesisId, $old);
|
||
}
|
||
|
||
/**
|
||
* Soft-delete multiple theses at once.
|
||
* @param int[] $thesisIds
|
||
*/
|
||
public function bulkDeleteTheses(array $thesisIds): void
|
||
{
|
||
if (empty($thesisIds)) {
|
||
return;
|
||
}
|
||
|
||
require_once __DIR__ . '/Audit.php';
|
||
$actor = Audit::actor();
|
||
|
||
foreach ($thesisIds as $id) {
|
||
$old = $this->fetchRow('theses', $id);
|
||
$this->pdo->prepare("UPDATE theses SET deleted_at = datetime('now') WHERE id = ?")->execute([$id]);
|
||
Audit::log($this, $actor, 'DELETE', 'theses', $id, $old);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get trashed (soft-deleted) theses for the admin corbeille view.
|
||
*/
|
||
public function getTrashedTheses(): array
|
||
{
|
||
$stmt = $this->pdo->query('
|
||
SELECT t.id, t.identifier, t.title, t.subtitle, t.year,
|
||
GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors,
|
||
t.deleted_at
|
||
FROM theses t
|
||
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
|
||
LEFT JOIN authors a ON ta.author_id = a.id
|
||
WHERE t.deleted_at IS NOT NULL
|
||
GROUP BY t.id
|
||
ORDER BY t.deleted_at DESC
|
||
');
|
||
return $stmt->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* Count trashed (soft-deleted) theses.
|
||
*/
|
||
public function countTrashedTheses(): int
|
||
{
|
||
return (int)$this->pdo->query(
|
||
'SELECT COUNT(*) FROM theses WHERE deleted_at IS NOT NULL'
|
||
)->fetchColumn();
|
||
}
|
||
|
||
/**
|
||
* Restore a soft-deleted thesis.
|
||
*/
|
||
public function restoreThesis(int $thesisId): void
|
||
{
|
||
require_once __DIR__ . '/Audit.php';
|
||
Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId,
|
||
$this->fetchRow('theses', $thesisId)
|
||
);
|
||
$this->pdo->prepare('UPDATE theses SET deleted_at = NULL WHERE id = ?')->execute([$thesisId]);
|
||
$new = $this->fetchRow('theses', $thesisId);
|
||
Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, null, $new);
|
||
}
|
||
|
||
/**
|
||
* Permanently delete a thesis (hard delete — files too).
|
||
* Only called from the corbeille for truly irreversible cleanup.
|
||
*/
|
||
public function hardDeleteThesis(int $thesisId): void
|
||
{
|
||
require_once __DIR__ . '/Audit.php';
|
||
$old = $this->fetchRow('theses', $thesisId);
|
||
|
||
// 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']);
|
||
}
|
||
}
|
||
|
||
$this->pdo->prepare('DELETE FROM theses WHERE id = ?')->execute([$thesisId]);
|
||
Audit::log($this, Audit::actor(), 'DELETE', 'theses', $thesisId, $old);
|
||
}
|
||
|
||
/**
|
||
* Restore a soft-deleted tag.
|
||
*/
|
||
public function restoreTag(int $id): void
|
||
{
|
||
require_once __DIR__ . '/Audit.php';
|
||
Audit::log($this, Audit::actor(), 'UPDATE', 'tags', $id, $this->fetchRow('tags', $id));
|
||
$this->pdo->prepare('UPDATE tags SET deleted_at = NULL WHERE id = ?')->execute([$id]);
|
||
}
|
||
|
||
/**
|
||
* Restore a soft-deleted language.
|
||
*/
|
||
public function restoreLanguage(int $id): void
|
||
{
|
||
require_once __DIR__ . '/Audit.php';
|
||
Audit::log($this, Audit::actor(), 'UPDATE', 'languages', $id, $this->fetchRow('languages', $id));
|
||
$this->pdo->prepare('UPDATE languages SET deleted_at = NULL WHERE id = ?')->execute([$id]);
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
/**
|
||
* Insert a thesis file record.
|
||
* sort_order defaults to (max existing sort_order + 1) for the thesis.
|
||
*/
|
||
public function insertThesisFile($thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType, ?string $displayLabel = null, ?int $sortOrder = null)
|
||
{
|
||
if ($sortOrder === null) {
|
||
$maxStmt = $this->pdo->prepare(
|
||
'SELECT COALESCE(MAX(sort_order), 0) FROM thesis_files WHERE thesis_id = ?'
|
||
);
|
||
$maxStmt->execute([$thesisId]);
|
||
$sortOrder = (int)$maxStmt->fetchColumn() + 1;
|
||
}
|
||
$stmt = $this->pdo->prepare('
|
||
INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label, sort_order)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
');
|
||
$stmt->execute([$thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType, $displayLabel, $sortOrder]);
|
||
return $this->pdo->lastInsertId();
|
||
}
|
||
|
||
/**
|
||
* Persist a new sort order for thesis files.
|
||
* $order is an array of file IDs in the desired order.
|
||
* Only files belonging to $thesisId are updated (safety guard).
|
||
*/
|
||
public function reorderThesisFiles(int $thesisId, array $order): void
|
||
{
|
||
$stmt = $this->pdo->prepare(
|
||
'UPDATE thesis_files SET sort_order = ? WHERE id = ? AND thesis_id = ?'
|
||
);
|
||
foreach ($order as $i => $fileId) {
|
||
$stmt->execute([(int)$i + 1, (int)$fileId, $thesisId]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update the display_label for a thesis file.
|
||
*/
|
||
public function updateThesisFileLabel(int $fileId, int $thesisId, ?string $label): void
|
||
{
|
||
$this->pdo->prepare(
|
||
'UPDATE thesis_files SET display_label = ? WHERE id = ? AND thesis_id = ?'
|
||
)->execute([$label ?: null, $fileId, $thesisId]);
|
||
}
|
||
|
||
/**
|
||
* Delete a single thesis file record by its ID and optionally remove the
|
||
* file from disk. Returns the file_path that was deleted (or null if not
|
||
* found), so the caller can clean up the filesystem.
|
||
*
|
||
* @param int $fileId Primary key of thesis_files row.
|
||
* @param int $thesisId Owning thesis ID (used as a safety guard).
|
||
* @return string|null The file_path that was stored, or null.
|
||
*/
|
||
public function deleteThesisFile(int $fileId, int $thesisId): ?string
|
||
{
|
||
$stmt = $this->pdo->prepare(
|
||
'SELECT * FROM thesis_files WHERE id = ? AND thesis_id = ? LIMIT 1'
|
||
);
|
||
$stmt->execute([$fileId, $thesisId]);
|
||
$row = $stmt->fetch();
|
||
if (!$row) {
|
||
return null;
|
||
}
|
||
$this->pdo->prepare('DELETE FROM thesis_files WHERE id = ?')->execute([$fileId]);
|
||
|
||
require_once __DIR__ . '/Audit.php';
|
||
Audit::log($this, Audit::actor(), 'DELETE', 'thesis_files', $fileId, $row);
|
||
|
||
return $row['file_path'];
|
||
}
|
||
|
||
/**
|
||
* Replace the cover image for a thesis: removes any existing cover record
|
||
* (and its file from disk), then inserts the new one.
|
||
*
|
||
* @deprecated Use ThesisFileHandler::handleCoverUpload() instead.
|
||
* Kept for backwards compatibility — may be called by old code.
|
||
*
|
||
* @param int $thesisId
|
||
* @param array|null $upload Single-file $_FILES entry.
|
||
* @return string|null Relative path of the new cover, or null.
|
||
*/
|
||
public function handleCoverUpload(int $thesisId, ?array $upload): ?string
|
||
{
|
||
if (!$upload || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||
return null;
|
||
}
|
||
|
||
require_once APP_ROOT . '/src/Controllers/ThesisFileHandler.php';
|
||
// Use the trait's version — but since it's a trait we can't call it directly.
|
||
// Delegate to the new convention: cover goes inside the thesis folder.
|
||
// For backwards compat, fall through to the old behaviour.
|
||
// The caller should migrate to using the trait directly.
|
||
|
||
$allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
|
||
$allowedExts = ['jpg', 'jpeg', 'png', 'webp'];
|
||
$maxBytes = 20 * 1024 * 1024;
|
||
|
||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||
$mimeType = $finfo->file($upload['tmp_name']);
|
||
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
|
||
|
||
if (!in_array($mimeType, $allowedMimes, true)
|
||
|| !in_array($ext, $allowedExts, true)
|
||
|| $upload['size'] > $maxBytes) {
|
||
error_log("handleCoverUpload: rejected {$upload['name']} ($mimeType, {$upload['size']} bytes)");
|
||
return null;
|
||
}
|
||
|
||
// Remove existing cover record + file
|
||
$existing = $this->pdo->prepare(
|
||
"SELECT id, file_path FROM thesis_files WHERE thesis_id = ? AND file_type = 'cover' LIMIT 1"
|
||
);
|
||
$existing->execute([$thesisId]);
|
||
if ($old = $existing->fetch()) {
|
||
$this->pdo->prepare('DELETE FROM thesis_files WHERE id = ?')->execute([$old['id']]);
|
||
if (!empty($old['file_path']) && defined('STORAGE_ROOT')) {
|
||
$abs = STORAGE_ROOT . '/' . $old['file_path'];
|
||
if (file_exists($abs)) {
|
||
@unlink($abs);
|
||
}
|
||
}
|
||
}
|
||
|
||
$coverDir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/covers/' : null;
|
||
if (!$coverDir) {
|
||
error_log('handleCoverUpload: STORAGE_ROOT not defined');
|
||
return null;
|
||
}
|
||
if (!is_dir($coverDir)) {
|
||
mkdir($coverDir, 0755, true);
|
||
}
|
||
|
||
$safeName = bin2hex(random_bytes(16)) . '.' . $ext;
|
||
$targetPath = $coverDir . $safeName;
|
||
|
||
if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
|
||
error_log("handleCoverUpload: move_uploaded_file failed for {$upload['name']}");
|
||
return null;
|
||
}
|
||
|
||
chmod($targetPath, 0644);
|
||
$relPath = 'covers/' . $safeName;
|
||
$this->insertThesisFile($thesisId, 'cover', $relPath, basename($upload['name']), $upload['size'], $mimeType);
|
||
error_log("handleCoverUpload: saved $relPath");
|
||
return $relPath;
|
||
}
|
||
|
||
// ========================================================================
|
||
// EXPORT HELPERS — used by ExportController
|
||
// ========================================================================
|
||
|
||
/**
|
||
* Fetch all theses (admin — includes unpublished) with every column
|
||
* needed for the CSV export.
|
||
*/
|
||
public function getAllThesesForExport(): array
|
||
{
|
||
return $this->pdo->query('
|
||
SELECT
|
||
t.id, t.identifier, t.title, t.subtitle, t.year,
|
||
o.name AS orientation,
|
||
ap.name AS ap_program,
|
||
ft.name AS finality_type,
|
||
at.name AS access_type,
|
||
lt.name AS license_name,
|
||
t.synopsis,
|
||
t.context_note,
|
||
t.remarks,
|
||
t.jury_points,
|
||
t.baiu_link
|
||
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
|
||
LEFT JOIN access_types at ON t.access_type_id = at.id
|
||
LEFT JOIN license_types lt ON t.license_id = lt.id
|
||
ORDER BY t.year DESC, t.title ASC
|
||
')->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* All thesis→author rows with author name and email.
|
||
*/
|
||
public function getAllThesisAuthorsForExport(): array
|
||
{
|
||
return $this->pdo->query('
|
||
SELECT ta.thesis_id, a.name, a.email
|
||
FROM thesis_authors ta
|
||
JOIN authors a ON a.id = ta.author_id
|
||
ORDER BY ta.thesis_id, ta.author_order
|
||
')->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* All thesis→supervisor rows with name.
|
||
*/
|
||
public function getAllThesisSupervisorsForExport(): array
|
||
{
|
||
return $this->pdo->query('
|
||
SELECT ts.thesis_id, s.name, ts.role, ts.is_external, ts.is_ulb
|
||
FROM thesis_supervisors ts
|
||
JOIN supervisors s ON s.id = ts.supervisor_id
|
||
ORDER BY ts.thesis_id, ts.supervisor_order
|
||
')->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* All thesis→tag rows with tag name.
|
||
*/
|
||
public function getAllThesisTagsForExport(): array
|
||
{
|
||
return $this->pdo->query('
|
||
SELECT tt.thesis_id, t.name
|
||
FROM thesis_tags tt
|
||
JOIN tags t ON t.id = tt.tag_id
|
||
ORDER BY tt.thesis_id, t.name
|
||
')->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* All thesis→language rows with language name.
|
||
*/
|
||
public function getAllThesisLanguagesForExport(): array
|
||
{
|
||
return $this->pdo->query('
|
||
SELECT tl.thesis_id, l.name
|
||
FROM thesis_languages tl
|
||
JOIN languages l ON l.id = tl.language_id
|
||
ORDER BY tl.thesis_id, l.name
|
||
')->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* All thesis→format rows with format name.
|
||
*/
|
||
public function getAllThesisFormatsForExport(): array
|
||
{
|
||
return $this->pdo->query('
|
||
SELECT tf.thesis_id, ft.name
|
||
FROM thesis_formats tf
|
||
JOIN format_types ft ON ft.id = tf.format_id
|
||
ORDER BY tf.thesis_id, ft.name
|
||
')->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* All thesis files for the file-export ZIP.
|
||
* Includes every thesis_files column + the thesis identifier for manifest
|
||
* construction.
|
||
*/
|
||
public function getAllThesisFilesForExport(): array
|
||
{
|
||
return $this->pdo->query('
|
||
SELECT tf.*, t.identifier
|
||
FROM thesis_files tf
|
||
JOIN theses t ON t.id = tf.thesis_id
|
||
ORDER BY t.year DESC, t.title ASC, tf.sort_order ASC
|
||
')->fetchAll();
|
||
}
|
||
|
||
// ========================================================================
|
||
// SINGLETON PATTERN ENFORCEMENT
|
||
// ========================================================================
|
||
|
||
// ========================================================================
|
||
// APROPOS CONTENTS (structured data formerly in config/apropos.php)
|
||
// ========================================================================
|
||
|
||
/**
|
||
* Get an apropos content value by key.
|
||
* @param string $key 'contacts', 'credits', or 'erg_url'
|
||
* @return array|string|null JSON-decoded array for contacts/credits, string for erg_url
|
||
*/
|
||
public function getAproposContent(string $key)
|
||
{
|
||
$stmt = $this->pdo->prepare('SELECT value FROM apropos_contents WHERE key = ?');
|
||
$stmt->execute([$key]);
|
||
$row = $stmt->fetch();
|
||
if (!$row) {
|
||
return null;
|
||
}
|
||
|
||
$decoded = json_decode($row['value'], true);
|
||
return is_array($decoded) ? $decoded : null;
|
||
}
|
||
|
||
/**
|
||
* Save an apropos content value by key (contacts JSON).
|
||
*/
|
||
public function saveAproposContent(string $key, array $value): void
|
||
{
|
||
$storedValue = json_encode($value, JSON_UNESCAPED_UNICODE);
|
||
$stmt = $this->pdo->prepare(
|
||
'INSERT INTO apropos_contents (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP'
|
||
);
|
||
$stmt->execute([$key, $storedValue]);
|
||
}
|
||
|
||
/**
|
||
* Get all apropos contents as [key => value] pairs (raw DB values).
|
||
*/
|
||
public function getAllAproposContents(): array
|
||
{
|
||
$stmt = $this->pdo->query('SELECT key, value, updated_at FROM apropos_contents ORDER BY key');
|
||
return $stmt->fetchAll();
|
||
}
|
||
|
||
// ========================================================================
|
||
// FORM HELP BLOCKS
|
||
// ========================================================================
|
||
|
||
/**
|
||
* Known form help block keys (mirrors the seeded rows in migration 004).
|
||
*/
|
||
public const FORM_HELP_KEYS = [
|
||
'partage_intro',
|
||
'fieldset_tfe_info',
|
||
'fieldset_synopsis',
|
||
'fieldset_languages',
|
||
'fieldset_keywords',
|
||
'fieldset_academic',
|
||
'fieldset_jury',
|
||
'fieldset_files',
|
||
'fieldset_metadata',
|
||
'fieldset_access',
|
||
'fieldset_email',
|
||
];
|
||
|
||
/**
|
||
* Get a single form help block by key. Returns '' when missing.
|
||
*/
|
||
public function getFormHelpBlock(string $key): string
|
||
{
|
||
$stmt = $this->pdo->prepare(
|
||
'SELECT content FROM form_help_blocks WHERE key = ? LIMIT 1'
|
||
);
|
||
$stmt->execute([$key]);
|
||
$val = $stmt->fetchColumn();
|
||
return ($val !== false) ? (string)$val : '';
|
||
}
|
||
|
||
/**
|
||
* Upsert a form help block (content only).
|
||
*/
|
||
public function setFormHelpBlock(string $key, string $content): void
|
||
{
|
||
if (!in_array($key, self::FORM_HELP_KEYS, true)) {
|
||
throw new Exception("Unknown form help block key: $key");
|
||
}
|
||
$this->pdo->prepare(
|
||
'INSERT INTO form_help_blocks (key, content, updated_at)
|
||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||
ON CONFLICT(key) DO UPDATE SET content = excluded.content,
|
||
updated_at = CURRENT_TIMESTAMP'
|
||
)->execute([$key, $content]);
|
||
}
|
||
|
||
/**
|
||
* Update a form help block's name.
|
||
*/
|
||
public function setFormHelpBlockName(string $key, string $name): void
|
||
{
|
||
if (!in_array($key, self::FORM_HELP_KEYS, true)) {
|
||
throw new Exception("Unknown form help block key: $key");
|
||
}
|
||
$this->pdo->prepare(
|
||
'UPDATE form_help_blocks SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?'
|
||
)->execute([$name, $key]);
|
||
}
|
||
|
||
/**
|
||
* Toggle a form help block's enabled state. Returns the new state (0 or 1).
|
||
*/
|
||
public function toggleFormHelpBlock(string $key): int
|
||
{
|
||
if (!in_array($key, self::FORM_HELP_KEYS, true)) {
|
||
throw new Exception("Unknown form help block key: $key");
|
||
}
|
||
$this->pdo->prepare(
|
||
'UPDATE form_help_blocks SET enabled = CASE enabled WHEN 1 THEN 0 ELSE 1 END,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE key = ?'
|
||
)->execute([$key]);
|
||
$stmt = $this->pdo->prepare('SELECT enabled FROM form_help_blocks WHERE key = ?');
|
||
$stmt->execute([$key]);
|
||
return (int)$stmt->fetchColumn();
|
||
}
|
||
|
||
/**
|
||
* Return all form help blocks, keyed by their key.
|
||
*/
|
||
public function getAllFormHelpBlocks(): array
|
||
{
|
||
$stmt = $this->pdo->query(
|
||
'SELECT key, name, content, enabled, updated_at FROM form_help_blocks'
|
||
);
|
||
$rows = $stmt->fetchAll();
|
||
$out = [];
|
||
foreach ($rows as $r) {
|
||
$out[$r['key']] = [
|
||
'name' => $r['name'],
|
||
'content' => $r['content'],
|
||
'enabled' => (int)$r['enabled'],
|
||
'updated_at' => $r['updated_at'],
|
||
];
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
// ========================================================================
|
||
// 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);
|
||
}
|
||
|
||
// ========================================================================
|
||
// FILE ACCESS RESTRICTION METHODS
|
||
// ========================================================================
|
||
|
||
/**
|
||
* Check if restricted files feature is enabled.
|
||
*/
|
||
public function isRestrictedFilesEnabled(): bool
|
||
{
|
||
return $this->getSetting('restricted_files_enabled', '0') === '1';
|
||
}
|
||
|
||
/**
|
||
* Create a new file access request.
|
||
*
|
||
* @param int $thesisId
|
||
* @param string $email
|
||
* @param string $justification Optional justification for non-ERG emails
|
||
* @return int New request ID
|
||
*/
|
||
public function createFileAccessRequest(int $thesisId, string $email, ?string $justification = null): int
|
||
{
|
||
$stmt = $this->pdo->prepare(
|
||
"INSERT INTO file_access_requests (thesis_id, email, justification, status)
|
||
VALUES (?, ?, ?, 'pending')"
|
||
);
|
||
$stmt->execute([$thesisId, $email, $justification]);
|
||
return (int)$this->pdo->lastInsertId();
|
||
}
|
||
|
||
/**
|
||
* Generate and store an access token for a request.
|
||
*
|
||
* @param int $requestId
|
||
* @param int $expiryDays Number of days until token expires (default: 30)
|
||
* @return string The generated token
|
||
*/
|
||
/**
|
||
* Generate and store a short-lived one-time email access token.
|
||
* Default: 24 hours. Token is invalidated after first redemption.
|
||
*
|
||
* @param int $requestId
|
||
* @param int $expiryHours Hours until token expires (default: 24)
|
||
* @return string The generated token (256-bit hex)
|
||
*/
|
||
public function generateAccessToken(int $requestId, int $expiryHours = 24): string
|
||
{
|
||
$token = bin2hex(random_bytes(32));
|
||
$expiresAt = date('Y-m-d H:i:s', time() + $expiryHours * 3600);
|
||
|
||
$stmt = $this->pdo->prepare(
|
||
'INSERT INTO file_access_tokens (request_id, token, expires_at)
|
||
VALUES (?, ?, ?)'
|
||
);
|
||
$stmt->execute([$requestId, $token, $expiresAt]);
|
||
|
||
return $token;
|
||
}
|
||
|
||
/**
|
||
* Validate a one-time email token and mark it as used (one-time use).
|
||
* Returns the thesis_id on success, null on failure.
|
||
* Logs the redemption attempt in file_access_audit.
|
||
*
|
||
* @param string $token
|
||
* @param string $ip Client IP address for audit log
|
||
* @param string $ua Client User-Agent for audit log
|
||
* @return int|null Thesis ID on success, null on invalid/expired/used
|
||
*/
|
||
/**
|
||
* Validate and redeem a one-time email access token.
|
||
*
|
||
* Returns ['thesis_id' => int, 'request_id' => int] on success.
|
||
* Returns null if the token is invalid, expired, or already used.
|
||
* Logs the redemption attempt in file_access_audit.
|
||
*
|
||
* @param string $token
|
||
* @param string $ip Client IP for audit log
|
||
* @param string $ua Client User-Agent for audit log
|
||
* @return array{thesis_id:int,request_id:int}|null
|
||
*/
|
||
public function redeemAccessToken(string $token, string $ip = '', string $ua = ''): ?array
|
||
{
|
||
// Look up the token — only valid if unused, unexpired, and approved
|
||
$stmt = $this->pdo->prepare(
|
||
"SELECT fat.id AS token_id, fat.request_id, fr.thesis_id
|
||
FROM file_access_tokens fat
|
||
JOIN file_access_requests fr ON fat.request_id = fr.id
|
||
WHERE fat.token = ?
|
||
AND fat.is_valid = 1
|
||
AND fat.used_at IS NULL
|
||
AND fat.expires_at > CURRENT_TIMESTAMP
|
||
AND fr.status = 'approved'"
|
||
);
|
||
$stmt->execute([$token]);
|
||
$row = $stmt->fetch();
|
||
|
||
if (!$row) {
|
||
// Log failed attempt if we can find the token at all
|
||
$check = $this->pdo->prepare(
|
||
'SELECT fat.request_id FROM file_access_tokens fat WHERE fat.token = ? LIMIT 1'
|
||
);
|
||
$check->execute([$token]);
|
||
$bad = $check->fetch();
|
||
if ($bad) {
|
||
$this->logAccessAudit((int)$bad['request_id'], 'invalid_or_expired', $ip, $ua);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Mark token as used (one-time)
|
||
$this->pdo->prepare(
|
||
'UPDATE file_access_tokens SET used_at = CURRENT_TIMESTAMP, is_valid = 0 WHERE id = ?'
|
||
)->execute([(int)$row['token_id']]);
|
||
|
||
// Audit log
|
||
$this->logAccessAudit((int)$row['request_id'], 'redeemed', $ip, $ua);
|
||
|
||
return [
|
||
'thesis_id' => (int)$row['thesis_id'],
|
||
'request_id' => (int)$row['request_id'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Create a long-lived browser session token after a successful link redemption.
|
||
* Stored in file_access_sessions (separate from one-time email tokens).
|
||
*
|
||
* @param int $requestId
|
||
* @param int $expiryDays Days until session expires (default: 30)
|
||
* @return string Session token (256-bit hex)
|
||
*/
|
||
public function createAccessSession(int $requestId, int $expiryDays = 30): string
|
||
{
|
||
$sessionToken = bin2hex(random_bytes(32));
|
||
$expiresAt = date('Y-m-d H:i:s', time() + $expiryDays * 86400);
|
||
|
||
$this->pdo->prepare(
|
||
'INSERT INTO file_access_sessions (request_id, session_token, expires_at)
|
||
VALUES (?, ?, ?)'
|
||
)->execute([$requestId, $sessionToken, $expiresAt]);
|
||
|
||
return $sessionToken;
|
||
}
|
||
|
||
/**
|
||
* Check if a browser session cookie grants valid access to a thesis.
|
||
*
|
||
* @param string $sessionToken Value from the HttpOnly cookie
|
||
* @param int $thesisId
|
||
* @return bool True if access is granted
|
||
*/
|
||
public function hasValidCookieAccess(string $sessionToken, int $thesisId): bool
|
||
{
|
||
$stmt = $this->pdo->prepare(
|
||
"SELECT COUNT(*) as count
|
||
FROM file_access_sessions fas
|
||
JOIN file_access_requests fr ON fas.request_id = fr.id
|
||
WHERE fas.session_token = ?
|
||
AND fas.is_valid = 1
|
||
AND fas.expires_at > CURRENT_TIMESTAMP
|
||
AND fr.status = 'approved'
|
||
AND fr.thesis_id = ?"
|
||
);
|
||
$stmt->execute([$sessionToken, $thesisId]);
|
||
$result = $stmt->fetch();
|
||
|
||
return $result && (int)$result['count'] > 0;
|
||
}
|
||
|
||
/**
|
||
* Write an entry to the access audit log.
|
||
*
|
||
* @param int $requestId
|
||
* @param string $event 'redeemed' | 'invalid_or_expired'
|
||
* @param string $ip
|
||
* @param string $ua
|
||
*/
|
||
public function logAccessAudit(int $requestId, string $event, string $ip, string $ua): void
|
||
{
|
||
$this->pdo->prepare(
|
||
'INSERT INTO file_access_audit (request_id, event, ip, user_agent)
|
||
VALUES (?, ?, ?, ?)'
|
||
)->execute([$requestId, $event, $ip, $ua]);
|
||
}
|
||
|
||
/**
|
||
* Get pending file access requests for admin review.
|
||
*
|
||
* @param int $limit Maximum number of requests to return
|
||
* @param int $offset Pagination offset
|
||
* @return array List of pending requests with thesis info
|
||
*/
|
||
public function getPendingAccessRequests(int $limit = 50, int $offset = 0): array
|
||
{
|
||
$sql = "
|
||
SELECT
|
||
far.id,
|
||
far.email,
|
||
far.justification,
|
||
far.created_at,
|
||
t.id as thesis_id,
|
||
t.title,
|
||
t.subtitle,
|
||
a.name as authors,
|
||
t.year
|
||
FROM file_access_requests far
|
||
JOIN theses t ON far.thesis_id = t.id
|
||
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
|
||
LEFT JOIN authors a ON ta.author_id = a.id AND ta.author_order = 1
|
||
WHERE far.status = 'pending'
|
||
ORDER BY far.created_at DESC
|
||
LIMIT ? OFFSET ?
|
||
";
|
||
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([$limit, $offset]);
|
||
return $stmt->fetchAll();
|
||
}
|
||
|
||
/**
|
||
* Approve a file access request and generate a token.
|
||
*
|
||
* @param int $requestId
|
||
* @param int|null $adminId Admin user ID (can be null if admin auth not tracked)
|
||
* @param int $expiryDays Token expiry in days
|
||
* @return string The generated access token
|
||
*/
|
||
/**
|
||
* Approve a file access request and generate a short-lived one-time email token.
|
||
*
|
||
* @param int $requestId
|
||
* @param int|null $adminId Admin user ID for audit trail
|
||
* @param int $expiryHours Hours until email link expires (default: 24)
|
||
* @return string The generated one-time access token
|
||
*/
|
||
public function approveAccessRequest(int $requestId, ?int $adminId = null, int $expiryHours = 24): string
|
||
{
|
||
$this->pdo->beginTransaction();
|
||
try {
|
||
// Update request status
|
||
$stmt = $this->pdo->prepare(
|
||
"UPDATE file_access_requests
|
||
SET status = 'approved',
|
||
approved_at = CURRENT_TIMESTAMP,
|
||
approved_by_admin_id = ?
|
||
WHERE id = ?"
|
||
);
|
||
$stmt->execute([$adminId, $requestId]);
|
||
|
||
// Generate short-lived one-time email token
|
||
$token = $this->generateAccessToken($requestId, $expiryHours);
|
||
|
||
$this->pdo->commit();
|
||
return $token;
|
||
} catch (\Throwable $e) {
|
||
$this->pdo->rollBack();
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reject a file access request.
|
||
*
|
||
* @param int $requestId
|
||
* @param string $adminNotes Optional rejection notes
|
||
*/
|
||
public function rejectAccessRequest(int $requestId, ?string $adminNotes = null): void
|
||
{
|
||
$stmt = $this->pdo->prepare(
|
||
"UPDATE file_access_requests
|
||
SET status = 'rejected',
|
||
approved_at = CURRENT_TIMESTAMP,
|
||
admin_notes = ?
|
||
WHERE id = ?"
|
||
);
|
||
$stmt->execute([$adminNotes, $requestId]);
|
||
}
|
||
|
||
/**
|
||
* Get access request by ID with thesis details.
|
||
*
|
||
* @param int $requestId
|
||
* @return array|null Request data or null if not found
|
||
*/
|
||
public function getAccessRequestById(int $requestId): ?array
|
||
{
|
||
$stmt = $this->pdo->prepare('
|
||
SELECT
|
||
far.*,
|
||
t.id as thesis_id,
|
||
t.title,
|
||
t.subtitle,
|
||
t.year,
|
||
a.name as authors
|
||
FROM file_access_requests far
|
||
JOIN theses t ON far.thesis_id = t.id
|
||
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
|
||
LEFT JOIN authors a ON ta.author_id = a.id AND ta.author_order = 1
|
||
WHERE far.id = ?
|
||
');
|
||
$stmt->execute([$requestId]);
|
||
$result = $stmt->fetch();
|
||
|
||
return $result ?: null;
|
||
}
|
||
|
||
/**
|
||
* Count pending access requests.
|
||
*/
|
||
public function countPendingAccessRequests(): int
|
||
{
|
||
$stmt = $this->pdo->query(
|
||
"SELECT COUNT(*) as count FROM file_access_requests WHERE status = 'pending'"
|
||
);
|
||
$result = $stmt->fetch();
|
||
return (int)$result['count'];
|
||
}
|
||
|
||
/**
|
||
* Check if an access request already exists for this email and thesis.
|
||
*
|
||
* @param int $thesisId
|
||
* @param string $email
|
||
* @return array|null Existing request or null
|
||
*/
|
||
public function getExistingAccessRequest(int $thesisId, string $email): ?array
|
||
{
|
||
$stmt = $this->pdo->prepare(
|
||
'SELECT id, status, created_at
|
||
FROM file_access_requests
|
||
WHERE thesis_id = ? AND email = ?
|
||
ORDER BY created_at DESC
|
||
LIMIT 1'
|
||
);
|
||
$stmt->execute([$thesisId, $email]);
|
||
$result = $stmt->fetch();
|
||
|
||
return $result ?: null;
|
||
}
|
||
|
||
/**
|
||
* Strip accents from a UTF-8 string (é→e, ç→c, etc.).
|
||
* Pure PHP fallback when iconv extension is not available.
|
||
*/
|
||
private static function stripAccents(string $str): string
|
||
{
|
||
if (function_exists('iconv')) {
|
||
$result = @iconv('UTF-8', 'ASCII//TRANSLIT', $str);
|
||
if ($result !== false) {
|
||
return strtolower($result);
|
||
}
|
||
}
|
||
|
||
// Manual transliteration table for common accented chars
|
||
static $map = null;
|
||
if ($map === null) {
|
||
$utf8 = [
|
||
'À','Á','Â','Ã','Ä','Å','Æ','à','á','â','ã','ä','å','æ',
|
||
'Ç','ç',
|
||
'È','É','Ê','Ë','è','é','ê','ë',
|
||
'Ì','Í','Î','Ï','ì','í','î','ï',
|
||
'Ð','ð',
|
||
'Ñ','ñ',
|
||
'Ò','Ó','Ô','Õ','Ö','Ø','ò','ó','ô','õ','ö','ø',
|
||
'Ù','Ú','Û','Ü','ù','ú','û','ü',
|
||
'Ý','ý','ÿ',
|
||
'Š','š','Ž','ž','Þ','þ','Œ','œ','ß',
|
||
];
|
||
$ascii = [
|
||
'A','A','A','A','A','A','AE','a','a','a','a','a','a','ae',
|
||
'C','c',
|
||
'E','E','E','E','e','e','e','e',
|
||
'I','I','I','I','i','i','i','i',
|
||
'D','d',
|
||
'N','n',
|
||
'O','O','O','O','O','O','o','o','o','o','o','o',
|
||
'U','U','U','U','u','u','u','u',
|
||
'Y','y','y',
|
||
'S','s','Z','z','TH','th','OE','oe','ss',
|
||
];
|
||
$map = array_combine($utf8, $ascii);
|
||
}
|
||
|
||
return strtolower(strtr($str, $map));
|
||
}
|
||
}
|