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). * * 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']); // ── 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'], '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) ──────────── $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; 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 * @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 * . * * @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"); } }