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 * @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=. On validation or DB failure, throws an Exception * (caller must flash the message and redirect back to the form). * * Two-phase execution: * 1. Validate + sanitise POST fields * 2. Find/create author record * 3. INSERT thesis row (status='draft') + link author (inside transaction) * 4. Link jury, languages, formats, tags (inside transaction) * 5. COMMIT (thesis visible only as draft) * 6. Handle file uploads: cover, thesis files (outside transaction) * 7. UPDATE status to 'active' (confirms file operations succeeded) * * @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) ─────────────────── // The first author's email is contact_interne (private, for confirmation emails). // contact_visible is stored on the thesis row, decoupled from the author email. $authorEntries = []; foreach ($data['authorNames'] as $i => $name) { $authorEntries[] = [ 'name' => $name, 'email' => $i === 0 ? ($data['contactInterne'] ?: null) : null, 'show_contact' => $i === 0 ? $data['showContact'] : false, ]; } $allAuthorsStr = implode(', ', $data['authorNames']); // ── 3–4. 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'], 'contact_visible' => $data['contactVisible'] ?? '', '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'], 'duration_pages' => $data['durationPages'], 'duration_minutes' => $data['durationMins'], 'has_annexes' => $data['hasAnnexes'] ? 1 : 0, ]); $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) ──────────── $objet = $data['objet'] ?? 'tfe'; $tf = $this->buildThesisFolder($data['annee'], $allAuthorsStr, $data['titre'], $objet); $folderName = $this->ensureUniqueFolder($tf['folderPath']); // Rebuild path with potentially modified folder name $folderPath = $objet . '/' . $data['annee'] . '/' . $folderName . '/'; $filePrefix = $folderName; // Determine upload path: FilePond async (JS enabled, hex IDs present) // vs. legacy multipart (no JS, or JS failed). The hidden filepond_mode // input starts disabled (value=0, not submitted); JS enables it on load. // Defense-in-depth: if filepond_mode=1 but no hex IDs, fall back to $_FILES. $useFilePond = !empty($post['filepond_mode']) && $this->hasFilePondQueueData($post); if ($useFilePond) { // 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); // ── 7. Two-phase commit: mark submission complete ────────────────── // The thesis was committed as status='draft' before file operations. // Now that all files are safely in place, promote to 'active'. // If any file operation had thrown, the draft would remain orphaned // for the periodic cleanup job (just cleanup-drafts). $this->db->setThesisStatus($thesisId, 'active'); error_log("[ThesisCreate] ACTIVE — thesis_id=$thesisId | all file operations succeeded"); 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 * @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: private email (backoffice field in admin mode, // confirmation_email field in student mode). Stored as the first // author's email — NOT as contact_visible. $contactInterne = trim($post['contact_interne'] ?? ''); if ($contactInterne === '' && !$adminMode) { $contactInterne = trim($post['confirmation_email'] ?? ''); } // contact_visible: public-facing contact shown on the TFE page. // Admin mode: from the dedicated contact_visible field. // Student mode: from the mail field (labeled "Contact visible" in fieldset-tfe-info). // These two concepts (contact_interne and contact_visible) are fully decoupled. $contactVisible = trim($post['contact_visible'] ?? ''); if ($contactVisible === '') { $contactVisible = trim($post['mail'] ?? ''); } // showContact: whether to show the contact publicly on the TFE page. // In admin mode, the checkbox always renders — unchecked = not sent. // In student mode (partage), the checkbox is not shown, so we // default to true when contact_visible is filled. if ($adminMode) { $showContact = !empty($post['contact_public']); } elseif (array_key_exists('contact_public', $post)) { $showContact = !empty($post['contact_public']); } else { $showContact = $contactVisible !== ''; } $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']); // Duration: separate pages (int) and time (h/m → total minutes) $durationPages = null; $durationPagesRaw = $post['duration_pages'] ?? null; if ($durationPagesRaw !== null && $durationPagesRaw !== '') { $durationPages = filter_var($durationPagesRaw, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); if ($durationPages === false || $durationPages <= 0) { $durationPages = null; } } $durationMins = null; $durationMinsRaw = $post['duration_minutes'] ?? null; if ($durationMinsRaw !== null && $durationMinsRaw !== '') { $durationMins = filter_var($durationMinsRaw, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); if ($durationMins === false || $durationMins <= 0) { $durationMins = null; } } // Annexes are optional — no validation required $hasAnnexes = !empty($post['has_annexes']); return compact( 'authorNames', 'contactInterne', 'contactVisible', 'showContact', 'annee', 'orientationId', 'apProgramId', 'finalityId', 'titre', 'subtitle', 'synopsis', 'juryMembers', 'keywords', 'languageIds', 'formatIds', 'licenseId', 'licenseCustom', 'lien', 'accessTypeId', 'objet', 'contextNote', 'remarks', 'juryPoints', 'exemplaireBaiu', 'exemplaireErg', 'cc2r', 'durationPages', 'durationMins', 'hasAnnexes' ); } // ── 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 * . * * @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"); } }