Files
xamxam/app/src/Controllers/ThesisCreateController.php
Pontoporeia 6e7c0c00e3 refactor: merge video/audio FilePond pools into TFE input
- Remove separate video/audio/peertube_video/peertube_audio pools from UI
- TFE pool now accepts all file types including video/audio
- When PeerTube is enabled, video/audio dropped into TFE pool auto-upload
  to PeerTube (process.php detects MIME and uploads immediately)
- PeerTube return IDs now encode type: peertube:video:UUID or peertube:audio:UUID
- load.php returns placeholder SVG for PeerTube files so they appear in FilePond
- Edit mode: all existing files (including PeerTube) shown in TFE FilePond pool
- Remove legacy  video/audio/peertube_* handling from both controllers
- Remove unused vide/audio/peertube_* entries from JS QUEUE_CONFIG
2026-05-19 00:08:06 +02:00

677 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/ThesisFileHandler.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, note d'intention, TFE, annexe, and PeerTube 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
{
use ThesisFileHandler;
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';
require_once APP_ROOT . '/src/ErrorHandler.php';
return new self(new Database());
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Get the identifier string for a thesis (caller supplies ID).
*/
public function getIdentifier(int $thesisId): string
{
return $this->db->getThesisIdentifier($thesisId);
}
// ── 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->getPredefinedLanguages(),
'formatTypes' => $this->db->getAllFormatTypes(),
'licenseTypes' => $this->db->getAllLicenseTypes(),
'enabledAccessTypes' => $this->db->getEnabledFormAccessTypes(),
];
}
// ── Write / action ────────────────────────────────────────────────────────
/**
* Validate and persist a new-thesis POST submission.
*
* On success, returns the new thesis ID so the caller can redirect to
* recapitulatif.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, 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, bool $adminMode = false): int
{
// ── 1. Validate + sanitise ────────────────────────────────────────────
$data = $this->validateAndSanitise($post, $adminMode);
// ── 1b. Duplicate detection ───────────────────────────────────────────
require_once APP_ROOT . '/src/DuplicateThesisException.php';
$duplicate = $this->db->findDuplicateThesis($data['titre'], $data['authorNames'], $data['annee']);
if ($duplicate !== null) {
throw new DuplicateThesisException(
$duplicate['id'],
$duplicate['identifier'],
$duplicate['title'],
$duplicate['author'],
$duplicate['year']
);
}
// ── 2. Build author entries (alphabetically sorted) ───────────────────
$authorEntries = [];
foreach ($data['authorNames'] as $i => $name) {
$authorEntries[] = [
'name' => $name,
'email' => $i === 0 ? ($data['mail'] ?: null) : null,
'show_contact' => $i === 0 ? $data['showContact'] : false,
];
}
$allAuthorsStr = implode(', ', $data['authorNames']);
// ── 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'],
'context_note' => $data['contextNote'],
'baiu_link' => $data['lien'],
'license_id' => $data['licenseId'],
'license_custom' => $data['licenseCustom'],
'access_type_id' => $data['accessTypeId'],
'objet' => $data['objet'],
'remarks' => $data['remarks'],
'jury_points' => $data['juryPoints'],
'exemplaire_baiu' => $data['exemplaireBaiu'],
'exemplaire_erg' => $data['exemplaireErg'],
'cc2r' => $data['cc2r'],
]);
$identifier = $this->db->getThesisIdentifier($thesisId);
error_log("[ThesisCreate] Step 1 OK — thesis_id=$thesisId ($identifier) | authors=" . count($authorEntries));
$this->db->setThesisAuthors($thesisId, $authorEntries);
error_log("[ThesisCreate] Step 2 OK — authors=" . json_encode($data['authorNames']));
$this->db->setThesisJury($thesisId, $data['juryMembers']);
error_log("[ThesisCreate] Step 3 OK — jury=" . count($data['juryMembers']));
$this->db->setThesisLanguages($thesisId, $data['languageIds']);
error_log("[ThesisCreate] Step 4 OK — languages=" . json_encode($data['languageIds']));
$this->db->setThesisFormats($thesisId, $data['formatIds']);
error_log("[ThesisCreate] Step 5 OK — formats=" . json_encode($data['formatIds']));
$this->db->setThesisTags($thesisId, $data['keywords']);
error_log("[ThesisCreate] Step 6 OK — tags=" . json_encode($data['keywords']));
$this->db->commit();
error_log("[ThesisCreate] COMMIT OK — thesis_id=$thesisId");
} catch (Exception $e) {
ErrorHandler::log('thesis_create_tx', $e, ['thesis_id' => $thesisId ?? null]);
$this->db->rollback();
throw $e;
}
// ── 5. File uploads (outside transaction — filesystem ops) ────────────
$tf = $this->buildThesisFolder($data['annee'], $allAuthorsStr, $data['titre']);
$folderName = $this->ensureUniqueFolder($tf['folderPath']);
// Rebuild path with potentially modified folder name
$folderPath = 'theses/' . $data['annee'] . '/' . $folderName . '/';
$filePrefix = $folderName;
if (!empty($post['filepond_mode'])) {
// New path: files already on server via async FilePond uploads
// Cover and note_intention also go through FilePond async flow
$this->handleFilePondSingleFile($thesisId, $post, 'cover', $folderPath, $filePrefix);
$this->handleFilePondSingleFile($thesisId, $post, 'note_intention', $folderPath, $filePrefix);
$nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'tfe', $folderPath, $filePrefix, 1);
$this->handleFilePondQueueFiles($thesisId, $post, 'annexe', $folderPath, $filePrefix, 0);
} else {
// Legacy path: files arrive via multipart $_FILES
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null, $folderPath, $filePrefix);
$this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix);
$queueFiles = $files['queue_file'] ?? [];
$qTfe = $this->extractFilesSubArray($queueFiles, 'tfe');
$qAnnexe = $this->extractFilesSubArray($queueFiles, 'annexe');
$nextNum = $this->handleTfeQueueFiles($thesisId, $qTfe, $folderPath, $filePrefix, 1);
$this->handleAnnexeQueueFiles($thesisId, $qAnnexe, $folderPath, $filePrefix);
}
// ── 6. Website URL — stored as thesis_files row ──────────────────────
$this->handleWebsiteUrl($thesisId, $post);
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, "Auteur·ice")) {
return 'auteurice';
}
if (str_contains($message, 'Titre du TFE')) {
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, 'promoteur')) {
return 'jury_promoteur';
}
if (str_contains($message, 'lecteur·ice interne')) {
return 'jury_lecteur_interne[]';
}
if (str_contains($message, 'lecteur·ice externe')) {
return 'jury_lecteur_externe[]';
}
if (str_contains($message, 'format')) {
return 'formats';
}
if (str_contains($message, 'mots-clés')) {
return 'tag';
}
if (str_contains($message, 'licence')) {
return 'license_id';
}
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, bool $adminMode = false): array
{
// Split authors by comma, trim, filter empty, sort alphabetically.
$authorRaw = $this->sanitiseString($post['auteurice'] ?? '');
$authorNames = [];
if ($authorRaw !== '') {
$authorNames = array_filter(array_map('trim', explode(',', $authorRaw)), fn ($n) => $n !== '');
$authorNames = array_values($authorNames);
sort($authorNames, SORT_NATURAL);
}
if (empty($authorNames)) {
throw new Exception("Le champ 'Auteur·ice(s)' est requis.");
}
// contact_interne (backoffice) takes precedence over mail (tfe-info fieldset)
$contactInterne = trim($post['contact_interne'] ?? '');
$mail = !empty($post['mail']) ? $this->sanitiseString($post['mail']) : '';
if ($contactInterne !== '') {
$mail = $contactInterne;
}
// contact_public: respected if present (admin form); defaults to true for student forms
// where the spec says contact is always visible when provided.
if (array_key_exists('contact_public', $post)) {
$showContact = !empty($post['contact_public']);
} else {
$showContact = $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) ?: null;
if (!$adminMode && !$orientationId) {
throw new Exception('Veuillez sélectionner une orientation.');
}
$apProgramId = filter_var($post['ap'] ?? '', FILTER_VALIDATE_INT) ?: null;
if (!$adminMode && !$apProgramId) {
throw new Exception('Veuillez sélectionner un Atelier Pratique.');
}
$finalityId = filter_var($post['finality'] ?? '', FILTER_VALIDATE_INT) ?: null;
if (!$adminMode && !$finalityId) {
throw new Exception('Veuillez sélectionner une finalité.');
}
$titre = $this->validateRequired($this->sanitiseString($post['titre'] ?? ''), 'Titre du TFE');
$subtitle = $this->sanitiseString($post['subtitle'] ?? '');
$synopsis = $this->validateRequired($this->sanitiseString($post['synopsis'] ?? ''), 'Synopsis');
// Jury members — new structure: separate interne/externe lecteurs
$juryMembers = [];
$hasPromoteur = false;
$hasPromoteurUlb = false;
$hasLecteurInt = false;
$hasLecteurExt = false;
// Promoteurs internes (accept both scalar and array)
$promoteurs = $post['jury_promoteur'] ?? null;
if ($promoteurs !== null && !is_array($promoteurs)) {
$promoteurs = [$promoteurs];
}
if (is_array($promoteurs)) {
foreach ($promoteurs as $name) {
$name = trim($name ?? '');
if ($name !== '') {
$juryMembers[] = ['name' => $name, 'role' => 'promoteur', 'is_external' => 0, 'is_ulb' => 0];
$hasPromoteur = true;
}
}
}
// Promoteurs ULB (accept both scalar and array)
$promoteursUlb = $post['jury_promoteur_ulb_name'] ?? null;
if ($promoteursUlb !== null && !is_array($promoteursUlb)) {
$promoteursUlb = [$promoteursUlb];
}
if (is_array($promoteursUlb)) {
foreach ($promoteursUlb as $name) {
$name = trim($name ?? '');
if ($name !== '') {
$juryMembers[] = ['name' => $name, 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 1];
$hasPromoteurUlb = true;
}
}
}
foreach ($post['jury_lecteur_interne'] ?? [] as $name) {
$name = trim($name);
if ($name !== '') {
$juryMembers[] = ['name' => $name, 'role' => 'lecteur', 'is_external' => 0];
$hasLecteurInt = true;
}
}
foreach ($post['jury_lecteur_externe'] ?? [] as $name) {
$name = trim($name);
if ($name !== '') {
$juryMembers[] = ['name' => $name, 'role' => 'lecteur', 'is_external' => 1];
$hasLecteurExt = true;
}
}
// Keep backwards compat with old jury_lecteurs (from old-style forms)
if (empty($juryMembers) || isset($post['jury_lecteurs'])) {
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,
];
if (isset($post['jury_lecteurs_ext'][$i]) && $post['jury_lecteurs_ext'][$i]) {
$hasLecteurExt = true;
} else {
$hasLecteurInt = true;
}
}
}
}
if (!$adminMode && !$hasPromoteur) {
throw new Exception('Veuillez indiquer au moins un·e promoteur·ice interne.');
}
if (!$adminMode && !$hasLecteurInt) {
throw new Exception('Veuillez indiquer au moins un·e lecteur·ice interne.');
}
if (!$adminMode && !$hasLecteurExt) {
throw new Exception('Veuillez indiquer au moins un·e lecteur·ice externe.');
}
// Keywords (max 10, min 3) — lowercased, spaces collapsed, deduplicated
$keywords = [];
$normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
if (isset($post['tag']) && is_array($post['tag'])) {
$keywords = array_values(array_unique(array_map(
$normalizeTag,
array_map(fn($t) => (string)$t, $post['tag'])
)));
$keywords = array_filter($keywords, fn($t) => $t !== '');
$keywords = array_slice($keywords, 0, 10);
} else {
$tagRaw = $this->sanitiseString($post['tag'] ?? '');
if ($tagRaw !== '') {
$keywords = array_map($normalizeTag, explode(',', $tagRaw));
}
}
$keywords = array_values(array_unique($keywords));
$keywords = array_filter($keywords, fn($t) => $t !== '');
$keywords = array_slice($keywords, 0, 10);
if (count($keywords) > 10) {
throw new Exception('Maximum 10 mots-clés autorisés.');
}
if (!$adminMode && count($keywords) < 3) {
throw new Exception('Veuillez indiquer au moins 3 mots-clés.');
}
// Languages (at least one required)
$languageIds = isset($post['languages']) && is_array($post['languages'])
? array_map('intval', $post['languages'])
: [];
// language_autre: pill-based component sends an array; also handle legacy comma-separated string
$autreRaw = $post['language_autre'] ?? '';
if (is_array($autreRaw)) {
foreach ($autreRaw as $langName) {
$langName = trim($langName);
if ($langName !== '') {
$languageIds[] = $this->db->getOrCreateLanguage($langName);
}
}
} elseif (is_string($autreRaw) && trim($autreRaw) !== '') {
foreach (array_map('trim', explode(',', $autreRaw)) as $langName) {
if ($langName !== '') {
$languageIds[] = $this->db->getOrCreateLanguage($langName);
}
}
}
if (!$adminMode && empty($languageIds)) {
throw new Exception('Veuillez sélectionner au moins une langue.');
}
// Formats (at least one required)
$formatIds = isset($post['formats']) && is_array($post['formats'])
? array_map('intval', $post['formats'])
: [];
if (!$adminMode && empty($formatIds)) {
throw new Exception('Veuillez sélectionner au moins un format.');
}
// Access type — must be one of the enabled types; default 2 (Interne)
$accessTypeId = filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT);
if ($accessTypeId === false || $accessTypeId <= 0) {
$accessTypeId = 2; // Interne
}
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
$licenseCustom = trim($post['license_custom'] ?? '');
// License is only required for non-admin when access type is Libre (1).
// For Interne (2) and Interdit (3), it's optional.
if (!$adminMode && $accessTypeId === 1 && !$licenseId && $licenseCustom === '') {
throw new Exception('Veuillez sélectionner une licence ou en préciser une.');
}
// Objet — restricted to valid values
$validObjet = ['tfe', 'thèse', 'frart'];
$objet = in_array($post['objet'] ?? '', $validObjet, true) ? $post['objet'] : 'tfe';
// 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.');
}
}
// Note contextuelle (optional, max 1500 chars)
$contextNote = $this->sanitiseString($post['context_note'] ?? '');
if (strlen($contextNote) > 1500) {
$contextNote = substr($contextNote, 0, 1500);
}
// Backoffice fields (admin only)
$remarks = trim($post['remarks'] ?? '');
$juryPoints = $post['jury_points'] ?? null;
if ($juryPoints !== null && $juryPoints !== '') {
$juryPoints = filter_var($juryPoints, FILTER_VALIDATE_FLOAT);
if ($juryPoints === false || $juryPoints < 0 || $juryPoints > 20) {
throw new Exception('La note du jury doit être comprise entre 0 et 20.');
}
}
$exemplaireBaiu = !empty($post['exemplaire_baiu']);
$exemplaireErg = !empty($post['exemplaire_erg']);
$cc2r = !empty($post['cc2r']);
// Annexes are optional — no validation required
$hasAnnexes = !empty($post['has_annexes']);
return compact(
'authorNames',
'mail',
'showContact',
'annee',
'orientationId',
'apProgramId',
'finalityId',
'titre',
'subtitle',
'synopsis',
'juryMembers',
'keywords',
'languageIds',
'formatIds',
'licenseId',
'licenseCustom',
'lien',
'accessTypeId',
'objet',
'contextNote',
'remarks',
'juryPoints',
'exemplaireBaiu',
'exemplaireErg',
'cc2r'
);
}
// ── Private: file uploads ─────────────────────────────────────────────────
// ── 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;
}
/**
* Upload PeerTube video/audio files from FilePond queue.
*
* Files arrive via PHP's nested $_FILES structure from
* <input name="queue_file[peertube_video][]">.
*
* @param int $thesisId Thesis to attach the results to.
* @param string $title Title to use on PeerTube.
* @param array|null $uploads Flat $_FILES-style array from extractFilesSubArray().
* @param string $fileType 'video' or 'audio'.
*/
protected function handlePeerTubeQueueFiles(int $thesisId, string $title, ?array $uploads, string $fileType): void
{
if (!$uploads || !is_array($uploads['name'] ?? null)) {
return;
}
require_once APP_ROOT . '/src/PeerTubeService.php';
if (!PeerTubeService::isEnabled($this->db)) {
return;
}
$count = count($uploads['name']);
for ($i = 0; $i < $count; $i++) {
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
continue;
}
try {
$result = PeerTubeService::upload(
$this->db,
$uploads['tmp_name'][$i],
$uploads['name'][$i],
$title,
''
);
// Store as peertube_ids:{uuid} so the embed template can extract the UUID
$storedPath = 'peertube_ids:' . $result['uuid'];
$this->db->insertThesisFile(
$thesisId,
$fileType,
$storedPath,
basename($uploads['name'][$i]),
$uploads['size'][$i],
$uploads['type'][$i] ?? 'application/octet-stream',
null,
null
);
error_log("ThesisCreateController: PeerTube upload OK → " . $result['watchUrl']);
} catch (\Throwable $e) {
error_log('ThesisCreateController: PeerTube upload failed — ' . $e->getMessage());
// Non-fatal: thesis already saved; admin can re-upload manually.
}
}
}
/**
* Store a website URL as a thesis_files row (file_type = 'website').
*
* The URL is stored in file_path; no filesystem operation is performed.
* label and sort_order from the POST are preserved.
*/
protected function handleWebsiteUrl(int $thesisId, array $post): void
{
$websiteUrl = trim($post['website_url'] ?? '');
if ($websiteUrl === '') {
return;
}
// Validate URL
$websiteUrl = filter_var($websiteUrl, FILTER_VALIDATE_URL);
if ($websiteUrl === false) {
error_log('ThesisCreateController: invalid website URL, skipping');
return;
}
$label = trim($post['website_label'] ?? '');
$sortOrder = isset($post['website_order']) ? (int)$post['website_order'] : null;
$fileName = rtrim(preg_replace('#^https?://#i', '', $websiteUrl), '/');
$this->db->insertThesisFile(
$thesisId,
'website',
$websiteUrl,
$fileName,
0,
'text/html',
$label !== '' ? $label : null,
$sortOrder
);
error_log("ThesisCreateController: website stored → $websiteUrl");
}
}