Extract ThesisCreateController; add Database publish methods

Consolidate action handlers into controller methods (todo/02-php-components.md).

src/ThesisCreateController.php (new, 435 lines)
  Mirrors ThesisEditController for the add-thesis flow.

  make()           — factory; instantiates Database via new Database()
  loadFormData()   — returns all lookup tables needed by admin/add.php
                     (orientations, apPrograms, finalityTypes, languages,
                      formatTypes, licenseTypes)
  submit(post, files) — full new-thesis creation pipeline:
    1. validateAndSanitise() — trims/strips HTML, validates required fields,
       year range, orientation/ap/finality IDs, language selection, max-10
       keywords, URL format; throws named Exception on failure
    2. findOrCreateAuthor() — reuses existing DB method
    3. Transaction: createThesis + setThesisJury + setThesisLanguages +
       setThesisFormats + setThesisTags; rolls back on any failure
    4. File uploads outside transaction: cover image (JPG/PNG only, stored in
       storage/covers/), banner via handleBannerUpload(), thesis files
       (PDF/JPG/PNG/MP4/ZIP/VTT, stored in storage/theses/YEAR/IDENT/,
       file_type auto-detected: caption/annex/main/other)
  autofocusFieldForError() — static; maps exception messages to field names
    for WCAG 3.3.1 autofocus on re-render (same contract as
    ThesisEditController::autofocusFieldForError)

admin/actions/formulaire.php  346 → 45 lines
  Now: bootstrap + CSRF guard + ThesisCreateController::make()->submit() +
  flash/redirect on error. All validation, DB logic, and file handling removed.

admin/add.php
  Lookup-table block (new Database() + 6 individual DB calls) replaced with
  ThesisCreateController::make()->loadFormData() + extract().

src/Database.php — two new methods added
  setPublished(int , bool ): void
    UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
  bulkSetPublished(int[] , bool ): void
    Same but with an IN (...) clause for multiple IDs

admin/actions/publish.php  100 → 65 lines
  Raw SQL (->prepare('UPDATE theses SET is_published = ?...')) replaced
  with ->setPublished() / ->bulkSetPublished(). No raw PDO calls remain
  in any action handler file.
This commit is contained in:
Pontoporeia
2026-04-06 14:37:56 +02:00
parent b1e70a2bf1
commit 2841e05716
7 changed files with 501 additions and 384 deletions

View File

@@ -871,6 +871,28 @@ class Database {
)->execute($params);
}
/**
* Set the published state of a single thesis.
*/
public function setPublished(int $thesisId, bool $published): void {
$this->pdo->prepare(
'UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
)->execute([$published ? 1 : 0, $thesisId]);
}
/**
* Set the published state for multiple theses at once.
* @param int[] $thesisIds
*/
public function bulkSetPublished(array $thesisIds, bool $published): void {
if (empty($thesisIds)) return;
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
$params = array_merge([$published ? 1 : 0], $thesisIds);
$this->pdo->prepare(
"UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)"
)->execute($params);
}
/**
* Get all access types (visibility options).
*/

View File

@@ -0,0 +1,435 @@
<?php
/**
* ThesisCreateController
*
* Centralises all validation, data-fetching, and persistence logic for the
* admin "add new thesis" workflow (admin/add.php + admin/actions/formulaire.php).
*
* Responsibilities:
* - Loading lookup tables for the add-form view (loadFormData)
* - Validating and sanitising POST submissions
* - Creating the thesis record, linking authors / jury / languages / formats /
* tags in a single database transaction
* - Handling cover image, banner, and multi-file uploads
* - WCAG 3.3.1: mapping validation exception messages to autofocus field hints
*
* The class has NO output side-effects; all redirects, flash writes, session
* mutations, and template rendering stay in the thin dispatcher files so the
* view layer remains easy to inspect and modify.
*/
class ThesisCreateController
{
/** Maximum allowed file size for thesis files (bytes). */
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
/** MIME types accepted for thesis files. */
private const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'application/pdf',
'video/mp4', 'application/zip', 'text/vtt',
];
/** File extensions accepted for thesis files. */
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip', 'vtt'];
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
// ── Factory ───────────────────────────────────────────────────────────────
/**
* Convenience factory — instantiates Database and returns a ready
* controller instance.
*/
public static function make(): self
{
require_once APP_ROOT . '/src/Database.php';
return new self(new Database());
}
// ── Read / view data ─────────────────────────────────────────────────────
/**
* Load all lookup tables required to render the add-thesis form.
*
* Returns a flat array of view variables:
* - 'orientations' orientation lookup rows
* - 'apPrograms' AP program lookup rows
* - 'finalityTypes' finality type lookup rows
* - 'languages' language lookup rows
* - 'formatTypes' format type lookup rows
* - 'licenseTypes' license type lookup rows
*
* @return array<string, mixed>
* @throws Exception on DB error.
*/
public function loadFormData(): array
{
return [
'orientations' => $this->db->getAllOrientations(),
'apPrograms' => $this->db->getAllAPPrograms(),
'finalityTypes' => $this->db->getAllFinalityTypes(),
'languages' => $this->db->getAllLanguages(),
'formatTypes' => $this->db->getAllFormatTypes(),
'licenseTypes' => $this->db->getAllLicenseTypes(),
];
}
// ── Write / action ────────────────────────────────────────────────────────
/**
* Validate and persist a new-thesis POST submission.
*
* On success, returns the new thesis ID so the caller can redirect to
* thanks.php?id=<n>. On validation or DB failure, throws an Exception
* (caller must flash the message and redirect back to the form).
*
* Execution order:
* 1. Validate + sanitise POST fields
* 2. Find/create author record
* 3. INSERT thesis row + link author (inside transaction)
* 4. Link jury, languages, formats, tags (inside transaction)
* 5. COMMIT
* 6. Handle file uploads: cover, banner, thesis files (outside transaction)
*
* @param array $post Sanitised $_POST array.
* @param array $files $_FILES array.
* @return int The newly created thesis ID.
* @throws Exception On validation or DB error.
*/
public function submit(array $post, array $files): int
{
// ── 1. Validate + sanitise ────────────────────────────────────────────
$data = $this->validateAndSanitise($post);
// ── 2. Find / create author ───────────────────────────────────────────
$authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null);
error_log("ThesisCreateController: author ID $authorId");
// ── 34. DB writes in a transaction ───────────────────────────────────
$this->db->beginTransaction();
try {
$thesisId = $this->db->createThesis([
'year' => $data['annee'],
'orientation_id' => $data['orientationId'],
'ap_program_id' => $data['apProgramId'],
'finality_id' => $data['finalityId'],
'title' => $data['titre'],
'subtitle' => $data['subtitle'],
'synopsis' => $data['synopsis'],
'file_size_info' => $data['durationInfo'],
'baiu_link' => $data['lien'],
'license_id' => $data['licenseId'],
'author_id' => $authorId,
]);
$identifier = $this->db->getThesisIdentifier($thesisId);
error_log("ThesisCreateController: created thesis #$thesisId ($identifier)");
$this->db->setThesisJury($thesisId, $data['juryMembers']);
$this->db->setThesisLanguages($thesisId, $data['languageIds']);
$this->db->setThesisFormats($thesisId, $data['formatIds']);
$this->db->setThesisTags($thesisId, $data['keywords']);
$this->db->commit();
} catch (Exception $e) {
$this->db->rollback();
throw $e;
}
// ── 5. File uploads (outside transaction — filesystem ops) ────────────
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null);
$this->db->handleBannerUpload($thesisId, $files['banner'] ?? null);
$this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null);
return $thesisId;
}
// ── WCAG 3.3.1 helper ─────────────────────────────────────────────────────
/**
* Map a validation exception message to the name of the field that should
* receive autofocus when the form is re-rendered after an error.
*
* Returns null when no field mapping is found.
*/
public static function autofocusFieldForError(string $message): ?string
{
if (str_contains($message, 'Nom/Prénom/Pseudo')) return 'auteurice';
if (str_contains($message, 'Titre du mémoire')) return 'titre';
if (str_contains($message, 'Synopsis')) return 'synopsis';
if (str_contains($message, 'Année invalide')) return 'année';
if (str_contains($message, 'orientation')) return 'orientation';
if (str_contains($message, 'Atelier Pratique')) return 'ap';
if (str_contains($message, 'finalité')) return 'finality';
if (str_contains($message, 'langue')) return 'languages';
if (str_contains($message, 'mots-clés')) return 'tag';
if (str_contains($message, 'Lien URL')) return 'lien';
return null;
}
// ── Private: validation ───────────────────────────────────────────────────
/**
* Validate and sanitise all POST fields for a new thesis submission.
*
* Returns a flat associative array of clean values.
*
* @param array $post Raw $_POST.
* @return array<string, mixed>
* @throws Exception on validation failure.
*/
private function validateAndSanitise(array $post): array
{
$auteurName = $this->validateRequired(
$this->sanitiseString($post['auteurice'] ?? ''),
'Nom/Prénom/Pseudo'
);
$mail = !empty($post['mail']) ? $this->sanitiseString($post['mail']) : '';
$annee = filter_var($post['année'] ?? '', FILTER_VALIDATE_INT);
if ($annee === false || $annee < 2000 || $annee > ((int) date('Y') + 1)) {
throw new Exception('Année invalide. Veuillez entrer une année valide.');
}
$orientationId = filter_var($post['orientation'] ?? '', FILTER_VALIDATE_INT);
if ($orientationId === false) {
throw new Exception('Veuillez sélectionner une orientation.');
}
$apProgramId = filter_var($post['ap'] ?? '', FILTER_VALIDATE_INT);
if ($apProgramId === false) {
throw new Exception('Veuillez sélectionner un Atelier Pratique.');
}
$finalityId = filter_var($post['finality'] ?? '', FILTER_VALIDATE_INT);
if ($finalityId === false) {
throw new Exception('Veuillez sélectionner une finalité.');
}
$titre = $this->validateRequired($this->sanitiseString($post['titre'] ?? ''), 'Titre du mémoire');
$subtitle = $this->sanitiseString($post['subtitle'] ?? '');
$synopsis = $this->validateRequired($this->sanitiseString($post['synopsis'] ?? ''), 'Synopsis');
$durationInfo = $this->sanitiseString($post['duration_info'] ?? '');
// Jury members
$juryMembers = [];
if (!empty(trim($post['jury_president'] ?? ''))) {
$juryMembers[] = ['name' => trim($post['jury_president']), 'role' => 'president', 'is_external' => 0];
}
if (!empty(trim($post['jury_promoteur'] ?? ''))) {
$juryMembers[] = [
'name' => trim($post['jury_promoteur']),
'role' => 'promoteur',
'is_external' => isset($post['jury_promoteur_ext']) ? 1 : 0,
];
}
foreach ($post['jury_lecteurs'] ?? [] as $i => $name) {
$name = trim($name);
if ($name !== '') {
$juryMembers[] = [
'name' => $name,
'role' => 'lecteur',
'is_external' => isset($post['jury_lecteurs_ext'][$i]) ? 1 : 0,
];
}
}
// Keywords (max 10)
$tagRaw = $this->sanitiseString($post['tag'] ?? '');
$keywords = $tagRaw !== '' ? array_map('trim', explode(',', $tagRaw)) : [];
if (count($keywords) > 10) {
throw new Exception('Maximum 10 mots-clés autorisés.');
}
// Languages (at least one required)
$languageIds = isset($post['languages']) && is_array($post['languages'])
? array_map('intval', $post['languages'])
: [];
if (empty($languageIds)) {
throw new Exception('Veuillez sélectionner au moins une langue.');
}
// Formats (optional)
$formatIds = isset($post['formats']) && is_array($post['formats'])
? array_map('intval', $post['formats'])
: [];
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
// External link (optional)
$lien = '';
if (!empty($post['lien'])) {
$lien = filter_var($post['lien'], FILTER_VALIDATE_URL);
if ($lien === false) {
throw new Exception('Lien URL invalide.');
}
}
return compact(
'auteurName', 'mail', 'annee', 'orientationId', 'apProgramId',
'finalityId', 'titre', 'subtitle', 'synopsis', 'durationInfo',
'juryMembers', 'keywords', 'languageIds', 'formatIds',
'licenseId', 'lien'
);
}
// ── Private: file uploads ─────────────────────────────────────────────────
/**
* Process an optional cover image upload and record it in thesis_files.
*
* @param int $thesisId
* @param array|null $upload Single-file $_FILES entry (may be null or have UPLOAD_ERR_NO_FILE).
*/
private function handleCoverUpload(int $thesisId, ?array $upload): void
{
if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) {
return;
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($upload['tmp_name']);
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
if (!in_array($mimeType, ['image/jpeg', 'image/png'], true)
|| !in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
error_log("ThesisCreateController: invalid cover MIME $mimeType, skipping");
return;
}
$coverDir = STORAGE_ROOT . '/covers/';
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("ThesisCreateController: failed to move cover to $targetPath");
return;
}
chmod($targetPath, 0644);
$relPath = 'covers/' . $safeName;
$this->db->insertThesisFile($thesisId, 'cover', $relPath, basename($upload['name']), $upload['size'], $mimeType);
error_log("ThesisCreateController: cover uploaded → $safeName");
}
/**
* Process multiple thesis-file uploads (PDFs, images, videos, ZIPs, VTTs).
*
* @param int $thesisId
* @param int $year Used for the storage sub-directory path.
* @param string $identifier Thesis identifier slug (e.g. "2024-003").
* @param array|null $uploads Multi-file $_FILES entry (may be null).
*/
private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads): void
{
if (!$uploads || !is_array($uploads['name'] ?? null)) {
return;
}
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$identifier}/";
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$count = count($uploads['name']);
for ($i = 0; $i < $count; $i++) {
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
continue;
}
if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) {
error_log("ThesisCreateController: upload error code {$uploads['error'][$i]} for {$uploads['name'][$i]}");
continue;
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($uploads['tmp_name'][$i]);
$ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));
// finfo may return 'text/plain' for WebVTT on some systems.
if ($mimeType === 'text/plain' && $ext === 'vtt') {
$mimeType = 'text/vtt';
}
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
|| !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
error_log("ThesisCreateController: invalid file type {$uploads['name'][$i]} ($mimeType), skipping");
continue;
}
if ($uploads['size'][$i] > self::MAX_FILE_SIZE) {
error_log("ThesisCreateController: file too large {$uploads['name'][$i]}, skipping");
continue;
}
$safeName = bin2hex(random_bytes(16)) . '.' . $ext;
$targetPath = $uploadDir . $safeName;
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
error_log("ThesisCreateController: failed to move file {$uploads['name'][$i]}");
continue;
}
chmod($targetPath, 0644);
$fileType = 'other';
if ($ext === 'vtt') {
$fileType = 'caption';
} elseif (stripos($uploads['name'][$i], 'annex') !== false) {
$fileType = 'annex';
} elseif ($ext === 'pdf') {
$fileType = 'main';
}
$relPath = "theses/{$year}/{$identifier}/" . $safeName;
$this->db->insertThesisFile(
$thesisId,
$fileType,
$relPath,
basename($uploads['name'][$i]),
$uploads['size'][$i],
$mimeType
);
error_log("ThesisCreateController: file uploaded → $safeName ($fileType)");
}
}
// ── Private: input helpers ────────────────────────────────────────────────
/**
* Trim and strip HTML tags from a string value.
* htmlspecialchars is applied at render time, not here.
*/
private function sanitiseString(string $input): string
{
return strip_tags(trim($input));
}
/**
* Assert that a string value is non-empty.
*
* @throws Exception if $value is empty.
*/
private function validateRequired(string $value, string $fieldName): string
{
if ($value === '') {
throw new Exception("Le champ '$fieldName' est requis.");
}
return $value;
}
}