From 95066de7b4d3911d925fd1a95448371ebb41873e Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Tue, 5 May 2026 10:31:06 +0200 Subject: [PATCH] standardise multi-author support across all forms - ThesisCreateController: comma-split auteurice, sort alphabetically, use setThesisAuthors() instead of hardcoded createThesis() author_id - Database::createThesis(): removed author_id param and hardcoded insert - Database::findDuplicateThesis(): accepts array of author names, matches any shared author via IN + DISTINCT - ThesisEditController::save(): sort authors alphabetically on save - File folder naming: slug from all authors alphabetically sorted - v_theses_full GROUP_CONCAT: ORDER BY a.name ASC for deterministic display - Migration 012_author_view_order.sql: rebuilds view with alphabetical order --- TODO.md | 9 +++ .../pending/012_author_view_order.sql | 59 +++++++++++++++++++ .../Controllers/ThesisCreateController.php | 45 +++++++++----- app/src/Controllers/ThesisEditController.php | 29 +++++---- app/src/Database.php | 47 +++++++++------ app/storage/schema.sql | 2 +- 6 files changed, 143 insertions(+), 48 deletions(-) create mode 100644 app/migrations/pending/012_author_view_order.sql diff --git a/TODO.md b/TODO.md index a39153b..0ecd024 100644 --- a/TODO.md +++ b/TODO.md @@ -36,6 +36,15 @@ - [x] `templates/admin/acces.php` — archive button, archived links collapsible section - [x] `scripts/setup-server.sh` — provision `/var/log/xamxam.log` with correct ownership +## Multi-author support +- [x] `ThesisCreateController::validateAndSanitise()` — comma-split `auteurice`, sort alphabetically, build author entries array +- [x] `Database::createThesis()` — removed hardcoded `author_id` insert; authors linked via `setThesisAuthors()` instead +- [x] `ThesisEditController::save()` — authors sorted alphabetically before `setThesisAuthors()` +- [x] `Database::findDuplicateThesis()` — accepts `array` of author names, matches any shared author via `IN` + `DISTINCT` +- [x] File folder naming — slug generated from all authors alphabetically sorted (both create and edit) +- [x] `v_theses_full` GROUP_CONCAT — `ORDER BY a.name ASC` for deterministic alphabetical display +- [x] Migration `012_author_view_order.sql` — rebuilds view with alphabetical author ordering + ## Fix remote 500s and broken TFE pages (post-deploy) - [x] `migrations/pending/008_share_links_is_archived.sql` — `ALTER TABLE share_links ADD COLUMN is_archived` (missing on remote; breaks `acces.php`) - [x] `migrations/pending/009_admin_audit_log.sql` — `CREATE TABLE admin_audit_log` (missing on remote) diff --git a/app/migrations/pending/012_author_view_order.sql b/app/migrations/pending/012_author_view_order.sql new file mode 100644 index 0000000..dfccae5 --- /dev/null +++ b/app/migrations/pending/012_author_view_order.sql @@ -0,0 +1,59 @@ +-- Rebuild v_theses_full to ORDER BY a.name in the GROUP_CONCAT for authors, +-- ensuring the view always returns authors alphabetically. +DROP VIEW IF EXISTS v_theses_full; +CREATE VIEW IF NOT EXISTS v_theses_full AS +SELECT + t.id, + t.identifier, + t.title, + t.subtitle, + t.year, + t.is_doctoral, + t.objet, + o.name as orientation, + ap.name as ap_program, + ft.name as finality_type, + t.synopsis, + t.context_note, + t.duration_minutes, + t.duration_pages, + t.file_size_info, + at.name as access_type, + lt.name as license_type, + t.license_id, + t.access_type_id, + t.jury_points, + t.submitted_at, + t.defense_date, + t.published_at, + t.is_published, + t.baiu_link, + t.banner_path, + GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors, + GROUP_CONCAT(DISTINCT s.name) as supervisors, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' THEN s.name END) as jury_promoteurs, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' THEN s.name END) as jury_lecteurs, + GROUP_CONCAT(DISTINCT l.name) as languages, + GROUP_CONCAT(DISTINCT fmt.name) as formats, + GROUP_CONCAT(DISTINCT tg.name) as keywords, + -- First author's email and contact-visibility flag + (SELECT a2.email FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as author_email, + (SELECT a2.show_contact FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as author_show_contact +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 finality_types ft ON t.finality_id = ft.id +LEFT JOIN access_types at ON t.access_type_id = at.id +LEFT JOIN license_types lt ON t.license_id = lt.id +LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id +LEFT JOIN authors a ON ta.author_id = a.id +LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id +LEFT JOIN supervisors s ON ts.supervisor_id = s.id +LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id +LEFT JOIN languages l ON tl.language_id = l.id +LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id +LEFT JOIN format_types fmt ON tf.format_id = fmt.id +LEFT JOIN thesis_tags tt ON t.id = tt.thesis_id +LEFT JOIN tags tg ON tt.tag_id = tg.id +GROUP BY t.id; diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index 67e3a94..bdfddbe 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -146,7 +146,7 @@ class ThesisCreateController // ── 1b. Duplicate detection ─────────────────────────────────────────── require_once APP_ROOT . '/src/DuplicateThesisException.php'; - $duplicate = $this->db->findDuplicateThesis($data['titre'], $data['auteurName'], $data['annee']); + $duplicate = $this->db->findDuplicateThesis($data['titre'], $data['authorNames'], $data['annee']); if ($duplicate !== null) { throw new DuplicateThesisException( $duplicate['id'], @@ -157,9 +157,17 @@ class ThesisCreateController ); } - // ── 2. Find / create author ─────────────────────────────────────────── - $authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null, $data['showContact']); - error_log("ThesisCreateController: author ID $authorId"); + // ── 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']); + $authorSlug = $this->generateAuthorSlug($allAuthorsStr); // ── 3–4. DB writes in a transaction ─────────────────────────────────── $this->db->beginTransaction(); @@ -178,12 +186,12 @@ class ThesisCreateController 'license_id' => $data['licenseId'], 'access_type_id' => $data['accessTypeId'], 'objet' => $data['objet'], - 'author_id' => $authorId, ]); $identifier = $this->db->getThesisIdentifier($thesisId); - error_log("ThesisCreateController: created thesis #$thesisId ($identifier)"); + error_log("ThesisCreateController: created thesis #$thesisId ($identifier) with " . count($authorEntries) . " author(s)"); + $this->db->setThesisAuthors($thesisId, $authorEntries); $this->db->setThesisJury($thesisId, $data['juryMembers']); $this->db->setThesisLanguages($thesisId, $data['languageIds']); $this->db->setThesisFormats($thesisId, $data['formatIds']); @@ -199,7 +207,7 @@ class ThesisCreateController // ── 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, $data['auteurName'], $post); + $this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $authorSlug, $post); return $thesisId; } @@ -263,10 +271,17 @@ class ThesisCreateController */ private function validateAndSanitise(array $post): array { - $auteurName = $this->validateRequired( - $this->sanitiseString($post['auteurice'] ?? ''), - 'Nom/Prénom/Pseudo' - ); + // 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 'Nom/Prénom/Pseudo' est requis."); + } $mail = !empty($post['mail']) ? $this->sanitiseString($post['mail']) : ''; $showContact = !empty($post['contact_public']) ? true : false; @@ -371,7 +386,7 @@ class ThesisCreateController } return compact( - 'auteurName', + 'authorNames', 'mail', 'showContact', 'confirmationEmail', @@ -445,16 +460,14 @@ class ThesisCreateController * @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). - * @param string $authorName Author name for folder and file naming. + * @param string $authorSlug Pre-computed author slug for folder and file naming. */ - private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads, string $authorName, array $post = []): void + private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads, string $authorSlug, array $post = []): void { if (!$uploads || !is_array($uploads['name'] ?? null)) { return; } - // Generate author slug and unique folder name - $authorSlug = $this->generateAuthorSlug($authorName); $folderName = $this->ensureUniqueFolder($year, $authorSlug); $uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/"; diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 72287dc..f5b8d55 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -181,20 +181,21 @@ class ThesisEditController 'is_published' => isset($post['is_published']), ]); - // ── 2. Authors ──────────────────────────────────────────────────── + // ── 2. Authors (alphabetically sorted) ───────────────────────────── $authorsRaw = trim($post['auteurice'] ?? ''); $showContact = !empty($post['contact_public']); - $authorEntries = []; + $authorNames = []; if ($authorsRaw !== '') { - foreach (array_map('trim', explode(',', $authorsRaw)) as $i => $name) { - if ($name !== '') { - $authorEntries[] = [ - 'name' => $name, - 'email' => $i === 0 ? ($post['mail'] ?? null) : null, - 'show_contact' => $i === 0 ? $showContact : false, - ]; - } - } + $authorNames = array_values(array_filter(array_map('trim', explode(',', $authorsRaw)), fn($n) => $n !== '')); + sort($authorNames, SORT_NATURAL); + } + $authorEntries = []; + foreach ($authorNames as $i => $name) { + $authorEntries[] = [ + 'name' => $name, + 'email' => $i === 0 ? ($post['mail'] ?? null) : null, + 'show_contact' => $i === 0 ? $showContact : false, + ]; } $this->db->setThesisAuthors($thesisId, $authorEntries); @@ -338,7 +339,11 @@ class ThesisEditController $year = (int)($post['année'] ?? date('Y')); $authorName = trim($post['auteurice'] ?? 'unknown'); - $authorSlug = $this->generateAuthorSlug($authorName); + + // Sort the raw comma-separated string alphabetically, then slugify. + $names = array_values(array_filter(array_map('trim', explode(',', $authorName)), fn($n) => $n !== '')); + sort($names, SORT_NATURAL); + $authorSlug = $this->generateAuthorSlug(implode(', ', $names)); // Per-file labels and sort orders submitted alongside the upload inputs $fileLabels = $post['file_labels'] ?? []; diff --git a/app/src/Database.php b/app/src/Database.php index c1404c4..300a9c5 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -838,7 +838,7 @@ class Database 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, + GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors, t.submitted_at, t.is_published, at.name as access_type @@ -983,18 +983,35 @@ class Database * @param int $year Proposed year. * @return array{id:int,identifier:string,title:string,author:string,year:int}|null */ - public function findDuplicateThesis(string $title, string $authorName, int $year): ?array + /** + * @param string $title Proposed title. + * @param string[] $authorNames Proposed author names (already trimmed, non-empty). + * @param int $year Proposed year. + * @return array{id:int,identifier:string,title:string,author:string,year:int}|null + */ + public function findDuplicateThesis(string $title, array $authorNames, int $year): ?array { - // Fetch all theses for the same year and author (case-insensitive). + if (empty($authorNames)) { + return null; + } + + // Fetch all theses for the same year that share any author with the submission. + $numAuthors = count($authorNames); + $ph = implode(',', array_fill(0, $numAuthors, 'LOWER(TRIM(?))')); + $params = array_merge([$year], $authorNames); $stmt = $this->pdo->prepare( - 'SELECT t.id, t.identifier, t.title, a.name AS author, t.year + "SELECT DISTINCT t.id, t.identifier, t.title, t.year, + GROUP_CONCAT(a2.name ORDER BY ta2.author_order ASC) AS authors FROM theses t - JOIN thesis_authors ta ON ta.thesis_id = t.id AND ta.author_order = 1 + JOIN thesis_authors ta ON ta.thesis_id = t.id JOIN authors a ON a.id = ta.author_id + JOIN thesis_authors ta2 ON ta2.thesis_id = t.id + JOIN authors a2 ON a2.id = ta2.author_id WHERE t.year = ? - AND LOWER(TRIM(a.name)) = LOWER(TRIM(?))' + AND LOWER(TRIM(a.name)) IN ({$ph}) + GROUP BY t.id" ); - $stmt->execute([$year, $authorName]); + $stmt->execute($params); $candidates = $stmt->fetchAll(); if (empty($candidates)) { @@ -1016,7 +1033,7 @@ class Database 'id' => (int)$row['id'], 'identifier' => $row['identifier'], 'title' => $row['title'], - 'author' => $row['author'], + 'author' => $row['authors'], 'year' => (int)$row['year'], ]; } @@ -1033,7 +1050,7 @@ class Database 'id' => (int)$row['id'], 'identifier' => $row['identifier'], 'title' => $row['title'], - 'author' => $row['author'], + 'author' => $row['authors'], 'year' => (int)$row['year'], ]; } @@ -1050,7 +1067,7 @@ class Database 'id' => (int)$row['id'], 'identifier' => $row['identifier'], 'title' => $row['title'], - 'author' => $row['author'], + 'author' => $row['authors'], 'year' => (int)$row['year'], ]; } @@ -1849,15 +1866,7 @@ class Database $objet, ]); - $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 (int)$this->pdo->lastInsertId(); } /** diff --git a/app/storage/schema.sql b/app/storage/schema.sql index 7af0da0..f7e8900 100644 --- a/app/storage/schema.sql +++ b/app/storage/schema.sql @@ -527,7 +527,7 @@ SELECT t.is_published, t.baiu_link, t.banner_path, - GROUP_CONCAT(DISTINCT a.name) as authors, + GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors, GROUP_CONCAT(DISTINCT s.name) as supervisors, GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president, GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' THEN s.name END) as jury_promoteurs,