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