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

@@ -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 ───────────────────────────────────────────
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);
// ── 34. 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}/";

View File

@@ -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'] ?? [];

View File

@@ -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();
}
/**

View File

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