feat: prevent duplicate TFE submissions with logging and user feedback

- Add DuplicateThesisException (typed, carries existing thesis metadata)
- Add Database::findDuplicateThesis(): matches on year + author + normalised
  title (exact, prefix, Levenshtein ≤10% of longer string)
- ThesisCreateController::submit() runs duplicate check before any DB write
  and throws DuplicateThesisException on match
- AppLogger::logDuplicate() writes status=duplicate entries to the JSON-lines
  log for audit purposes
- App::flash/consumeFlash extended to support 'warning' flash type
- admin/actions/formulaire.php: catches DuplicateThesisException, logs it,
  flashes an HTML warning toast with a clickable link to the existing thesis,
  and repopulates the form fields
- partage/index.php: same catch block; surfaces a plain-text flash-warning
  banner on the student form with identifier, title, and year of the match;
  form is repopulated via session
- toast.php: renders toast--warning variant
- admin.css: .toast--warning + link colour rules
- form.css: .flash-warning style for the partage form
This commit is contained in:
Pontoporeia
2026-05-04 16:29:31 +02:00
parent 0a05f3911c
commit a2cba6d3c0
35 changed files with 1726 additions and 1302 deletions

View File

@@ -1,4 +1,5 @@
<?php
/**
* ThesisCreateController
*
@@ -143,6 +144,19 @@ class ThesisCreateController
// ── 1. Validate + sanitise ────────────────────────────────────────────
$data = $this->validateAndSanitise($post);
// ── 1b. Duplicate detection ───────────────────────────────────────────
require_once APP_ROOT . '/src/DuplicateThesisException.php';
$duplicate = $this->db->findDuplicateThesis($data['titre'], $data['auteurName'], $data['annee']);
if ($duplicate !== null) {
throw new DuplicateThesisException(
$duplicate['id'],
$duplicate['identifier'],
$duplicate['title'],
$duplicate['author'],
$duplicate['year']
);
}
// ── 2. Find / create author ───────────────────────────────────────────
$authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null, $data['showContact']);
error_log("ThesisCreateController: author ID $authorId");
@@ -200,17 +214,39 @@ class ThesisCreateController
*/
public static function autofocusFieldForError(string $message): ?string
{
if (str_contains($message, 'Nom/Prénom/Pseudo')) return 'auteurice';
if (str_contains($message, 'Titre du mémoire')) 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, 'mots-clés')) return 'tag';
if (str_contains($message, 'Lien URL')) return 'lien';
if (str_contains($message, 'e-mail de confirmation')) return 'confirmation_email';
if (str_contains($message, 'Nom/Prénom/Pseudo')) {
return 'auteurice';
}
if (str_contains($message, 'Titre du mémoire')) {
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, 'mots-clés')) {
return 'tag';
}
if (str_contains($message, 'Lien URL')) {
return 'lien';
}
if (str_contains($message, 'e-mail de confirmation')) {
return 'confirmation_email';
}
return null;
}
@@ -335,10 +371,26 @@ class ThesisCreateController
}
return compact(
'auteurName', 'mail', 'showContact', 'confirmationEmail', 'annee', 'orientationId', 'apProgramId',
'finalityId', 'titre', 'subtitle', 'synopsis', 'durationInfo',
'juryMembers', 'keywords', 'languageIds', 'formatIds',
'licenseId', 'lien', 'accessTypeId', 'objet'
'auteurName',
'mail',
'showContact',
'confirmationEmail',
'annee',
'orientationId',
'apProgramId',
'finalityId',
'titre',
'subtitle',
'synopsis',
'durationInfo',
'juryMembers',
'keywords',
'languageIds',
'formatIds',
'licenseId',
'lien',
'accessTypeId',
'objet'
);
}
@@ -496,11 +548,21 @@ class ThesisCreateController
*/
private function detectFileType(string $mimeType, string $ext, string $originalName): string
{
if ($ext === 'vtt' || $mimeType === 'text/vtt') return 'caption';
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) return 'audio';
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) return 'video';
if ($mimeType === 'application/pdf' || $ext === 'pdf') return 'main';
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) return 'image';
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
return 'caption';
}
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
return 'audio';
}
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
return 'video';
}
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
return 'main';
}
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
return 'image';
}
return 'other';
}