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
This commit is contained in:
Pontoporeia
2026-05-05 10:31:06 +02:00
parent 125c501f40
commit 95066de7b4
6 changed files with 143 additions and 48 deletions

View File

@@ -36,6 +36,15 @@
- [x] `templates/admin/acces.php` — archive button, archived links collapsible section - [x] `templates/admin/acces.php` — archive button, archived links collapsible section
- [x] `scripts/setup-server.sh` — provision `/var/log/xamxam.log` with correct ownership - [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) ## 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/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) - [x] `migrations/pending/009_admin_audit_log.sql``CREATE TABLE admin_audit_log` (missing on remote)

View File

@@ -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;

View File

@@ -146,7 +146,7 @@ class ThesisCreateController
// ── 1b. Duplicate detection ─────────────────────────────────────────── // ── 1b. Duplicate detection ───────────────────────────────────────────
require_once APP_ROOT . '/src/DuplicateThesisException.php'; 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) { if ($duplicate !== null) {
throw new DuplicateThesisException( throw new DuplicateThesisException(
$duplicate['id'], $duplicate['id'],
@@ -157,9 +157,17 @@ class ThesisCreateController
); );
} }
// ── 2. Find / create author ─────────────────────────────────────────── // ── 2. Build author entries (alphabetically sorted) ───────────────────
$authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null, $data['showContact']); $authorEntries = [];
error_log("ThesisCreateController: author ID $authorId"); 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);
// ── 34. DB writes in a transaction ─────────────────────────────────── // ── 34. DB writes in a transaction ───────────────────────────────────
$this->db->beginTransaction(); $this->db->beginTransaction();
@@ -178,12 +186,12 @@ class ThesisCreateController
'license_id' => $data['licenseId'], 'license_id' => $data['licenseId'],
'access_type_id' => $data['accessTypeId'], 'access_type_id' => $data['accessTypeId'],
'objet' => $data['objet'], 'objet' => $data['objet'],
'author_id' => $authorId,
]); ]);
$identifier = $this->db->getThesisIdentifier($thesisId); $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->setThesisJury($thesisId, $data['juryMembers']);
$this->db->setThesisLanguages($thesisId, $data['languageIds']); $this->db->setThesisLanguages($thesisId, $data['languageIds']);
$this->db->setThesisFormats($thesisId, $data['formatIds']); $this->db->setThesisFormats($thesisId, $data['formatIds']);
@@ -199,7 +207,7 @@ class ThesisCreateController
// ── 5. File uploads (outside transaction — filesystem ops) ──────────── // ── 5. File uploads (outside transaction — filesystem ops) ────────────
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null); $this->handleCoverUpload($thesisId, $files['couverture'] ?? null);
$this->db->handleBannerUpload($thesisId, $files['banner'] ?? 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; return $thesisId;
} }
@@ -263,10 +271,17 @@ class ThesisCreateController
*/ */
private function validateAndSanitise(array $post): array private function validateAndSanitise(array $post): array
{ {
$auteurName = $this->validateRequired( // Split authors by comma, trim, filter empty, sort alphabetically.
$this->sanitiseString($post['auteurice'] ?? ''), $authorRaw = $this->sanitiseString($post['auteurice'] ?? '');
'Nom/Prénom/Pseudo' $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']) : ''; $mail = !empty($post['mail']) ? $this->sanitiseString($post['mail']) : '';
$showContact = !empty($post['contact_public']) ? true : false; $showContact = !empty($post['contact_public']) ? true : false;
@@ -371,7 +386,7 @@ class ThesisCreateController
} }
return compact( return compact(
'auteurName', 'authorNames',
'mail', 'mail',
'showContact', 'showContact',
'confirmationEmail', 'confirmationEmail',
@@ -445,16 +460,14 @@ class ThesisCreateController
* @param int $year Used for the storage sub-directory path. * @param int $year Used for the storage sub-directory path.
* @param string $identifier Thesis identifier slug (e.g. "2024-003"). * @param string $identifier Thesis identifier slug (e.g. "2024-003").
* @param array|null $uploads Multi-file $_FILES entry (may be null). * @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)) { if (!$uploads || !is_array($uploads['name'] ?? null)) {
return; return;
} }
// Generate author slug and unique folder name
$authorSlug = $this->generateAuthorSlug($authorName);
$folderName = $this->ensureUniqueFolder($year, $authorSlug); $folderName = $this->ensureUniqueFolder($year, $authorSlug);
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/"; $uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/";

View File

@@ -181,20 +181,21 @@ class ThesisEditController
'is_published' => isset($post['is_published']), 'is_published' => isset($post['is_published']),
]); ]);
// ── 2. Authors ──────────────────────────────────────────────────── // ── 2. Authors (alphabetically sorted) ─────────────────────────────
$authorsRaw = trim($post['auteurice'] ?? ''); $authorsRaw = trim($post['auteurice'] ?? '');
$showContact = !empty($post['contact_public']); $showContact = !empty($post['contact_public']);
$authorEntries = []; $authorNames = [];
if ($authorsRaw !== '') { if ($authorsRaw !== '') {
foreach (array_map('trim', explode(',', $authorsRaw)) as $i => $name) { $authorNames = array_values(array_filter(array_map('trim', explode(',', $authorsRaw)), fn($n) => $n !== ''));
if ($name !== '') { sort($authorNames, SORT_NATURAL);
$authorEntries[] = [ }
'name' => $name, $authorEntries = [];
'email' => $i === 0 ? ($post['mail'] ?? null) : null, foreach ($authorNames as $i => $name) {
'show_contact' => $i === 0 ? $showContact : false, $authorEntries[] = [
]; 'name' => $name,
} 'email' => $i === 0 ? ($post['mail'] ?? null) : null,
} 'show_contact' => $i === 0 ? $showContact : false,
];
} }
$this->db->setThesisAuthors($thesisId, $authorEntries); $this->db->setThesisAuthors($thesisId, $authorEntries);
@@ -338,7 +339,11 @@ class ThesisEditController
$year = (int)($post['année'] ?? date('Y')); $year = (int)($post['année'] ?? date('Y'));
$authorName = trim($post['auteurice'] ?? 'unknown'); $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 // Per-file labels and sort orders submitted alongside the upload inputs
$fileLabels = $post['file_labels'] ?? []; $fileLabels = $post['file_labels'] ?? [];

View File

@@ -838,7 +838,7 @@ class Database
t.id, t.identifier, t.title, t.subtitle, t.year, t.id, t.identifier, t.title, t.subtitle, t.year,
o.name as orientation, o.name as orientation,
ap.name as ap_program, 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.submitted_at,
t.is_published, t.is_published,
at.name as access_type at.name as access_type
@@ -983,18 +983,35 @@ class Database
* @param int $year Proposed year. * @param int $year Proposed year.
* @return array{id:int,identifier:string,title:string,author:string,year:int}|null * @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( $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 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 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 = ? 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(); $candidates = $stmt->fetchAll();
if (empty($candidates)) { if (empty($candidates)) {
@@ -1016,7 +1033,7 @@ class Database
'id' => (int)$row['id'], 'id' => (int)$row['id'],
'identifier' => $row['identifier'], 'identifier' => $row['identifier'],
'title' => $row['title'], 'title' => $row['title'],
'author' => $row['author'], 'author' => $row['authors'],
'year' => (int)$row['year'], 'year' => (int)$row['year'],
]; ];
} }
@@ -1033,7 +1050,7 @@ class Database
'id' => (int)$row['id'], 'id' => (int)$row['id'],
'identifier' => $row['identifier'], 'identifier' => $row['identifier'],
'title' => $row['title'], 'title' => $row['title'],
'author' => $row['author'], 'author' => $row['authors'],
'year' => (int)$row['year'], 'year' => (int)$row['year'],
]; ];
} }
@@ -1050,7 +1067,7 @@ class Database
'id' => (int)$row['id'], 'id' => (int)$row['id'],
'identifier' => $row['identifier'], 'identifier' => $row['identifier'],
'title' => $row['title'], 'title' => $row['title'],
'author' => $row['author'], 'author' => $row['authors'],
'year' => (int)$row['year'], 'year' => (int)$row['year'],
]; ];
} }
@@ -1849,15 +1866,7 @@ class Database
$objet, $objet,
]); ]);
$thesisId = (int)$this->pdo->lastInsertId(); return (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;
} }
/** /**

View File

@@ -527,7 +527,7 @@ SELECT
t.is_published, t.is_published,
t.baiu_link, t.baiu_link,
t.banner_path, 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 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 = '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 = 'promoteur' THEN s.name END) as jury_promoteurs,