Error tests, FK violations fix

- ErrorHandler tests: 77 assertions covering FK extraction, normalization, dedup, edge cases. Fix FK table map for child tables.
- Fix FK violation: (int)null → 0 in createThesis for orientation/ap/finality/license FK columns. Add FK value logging to updateThesis.
- Add CURRENT_ISSUES.md with summary of FK violation, dev debugging, and tag dedup status for next conversation
This commit is contained in:
Pontoporeia
2026-05-09 21:36:42 +02:00
parent a80b2c08bf
commit 6cc0e407f3
38 changed files with 1515 additions and 82 deletions

View File

@@ -18,6 +18,7 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SmtpRelay.php';
require_once APP_ROOT . '/src/AdminLogger.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
$db = Database::getInstance();
$logger = AdminLogger::make();
@@ -59,7 +60,7 @@ try {
$logger->logAccessRequest($requestId, 'approve', $request['email'], $request['title']);
App::flash('success', "Demande approuvée. Email envoyé à {$request['email']}.");
} catch (SmtpSendException $e) {
error_log('[access-request] Email delivery failed after approval: ' . $e->getMessage());
ErrorHandler::log('access_request_smtp', $e, ['request_id' => $requestId]);
$logger->logAccessRequest($requestId, 'approve', $request['email'], $request['title']);
$smtpMsg = $e->isRecipientRejected()
? "Demande approuvée, mais l'email n'a pas pu être délivré : adresse inconnue ({$request['email']})."
@@ -78,7 +79,7 @@ try {
exit;
} catch (Exception $e) {
error_log('Access request action failed: ' . $e->getMessage());
ErrorHandler::log('access_request', $e);
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Erreur lors du traitement']);
}

View File

@@ -21,6 +21,7 @@ if (!in_array($aproposKey, $allowedKeys)) {
require_once __DIR__ . '/../../../src/Database.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
require_once __DIR__ . '/../../../src/ErrorHandler.php';
try {
$db = new Database();
@@ -54,8 +55,8 @@ try {
AdminLogger::make()->logAproposEdit($aproposKey);
App::flash('success', "Contenu « $aproposKey » mis à jour avec succès.");
} catch (Exception $e) {
error_log("Apropos save error: " . $e->getMessage());
die("Erreur lors de la sauvegarde : " . htmlspecialchars($e->getMessage()));
ErrorHandler::log('apropos', $e);
die('Erreur lors de la sauvegarde : ' . htmlspecialchars(ErrorHandler::userMessage($e)));
}
header('Location: /admin/contenus.php');

View File

@@ -6,6 +6,7 @@ AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
require_once __DIR__ . '/../../../src/ErrorHandler.php';
// CSRF validation
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
@@ -57,8 +58,8 @@ try {
}
} catch (Exception $e) {
error_log('delete.php error: ' . $e->getMessage());
App::flash('error', 'Erreur lors de la suppression : ' . $e->getMessage());
ErrorHandler::log('thesis_delete', $e);
App::flash('error', 'Erreur lors de la suppression : ' . ErrorHandler::userMessage($e));
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

View File

@@ -26,6 +26,7 @@ if ($thesisId <= 0) {
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
require_once APP_ROOT . '/src/AdminLogger.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
try {
$ctrl = ThesisEditController::create();
@@ -41,9 +42,8 @@ try {
exit();
} catch (Exception $e) {
error_log("Edit action error: " . $e->getMessage());
App::flash('error', $e->getMessage());
ErrorHandler::log('thesis_edit', $e, ['thesis_id' => $thesisId]);
App::flash('error', ErrorHandler::userMessage($e));
// WCAG 3.3.1 — map error message to field name for autofocus on re-render.
$autofocusField = ThesisEditController::autofocusFieldForError($e->getMessage());

View File

@@ -15,6 +15,7 @@ AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Controllers/ExportController.php';
require_once APP_ROOT . '/src/AdminLogger.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
try {
$controller = ExportController::create();
@@ -40,9 +41,9 @@ try {
// Clean up temp file
@unlink($zipPath);
} catch (Exception $e) {
error_log('Files export error: ' . $e->getMessage());
ErrorHandler::log('export_files', $e);
http_response_code(500);
exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars($e->getMessage()));
exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars(ErrorHandler::userMessage($e)));
}
exit;

View File

@@ -15,6 +15,7 @@ AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Controllers/ExportController.php';
require_once APP_ROOT . '/src/AdminLogger.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
$doCsv = !empty($_GET['csv']);
$doFiles = !empty($_GET['files']);
@@ -69,9 +70,9 @@ if ($doFiles) {
readfile($zipPath);
@unlink($zipPath);
} catch (Exception $e) {
error_log('Files export error: ' . $e->getMessage());
ErrorHandler::log('export', $e);
http_response_code(500);
exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars($e->getMessage()));
exit('Erreur lors de la création de l\'archive : ' . htmlspecialchars(ErrorHandler::userMessage($e)));
}
}

View File

@@ -19,6 +19,7 @@ $content = $_POST['content'] ?? '';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/AdminLogger.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
$db = new Database();
if (!in_array($key, Database::FORM_HELP_KEYS, true)) {
@@ -32,8 +33,8 @@ try {
AdminLogger::make()->logFormStructureEdit($key);
App::flash('success', 'Bloc « ' . htmlspecialchars($key) . ' » mis à jour.');
} catch (Exception $e) {
error_log('form-help save error: ' . $e->getMessage());
App::flash('error', 'Erreur lors de la sauvegarde : ' . htmlspecialchars($e->getMessage()));
ErrorHandler::log('form_help', $e);
App::flash('error', 'Erreur lors de la sauvegarde : ' . ErrorHandler::userMessage($e));
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

View File

@@ -26,6 +26,7 @@ require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
require_once APP_ROOT . '/src/AppLogger.php';
require_once APP_ROOT . '/src/AdminLogger.php';
require_once APP_ROOT . '/src/DuplicateThesisException.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
$logger = new AppLogger();
$adminLogger = AdminLogger::make();
@@ -47,8 +48,7 @@ try {
} catch (DuplicateThesisException $e) {
$logger->logDuplicate('admin', $authorName, $e->existingThesisId, $e->existingIdentifier);
error_log('ThesisCreateController duplicate: ' . $e->getMessage());
ErrorHandler::log('thesis_create_duplicate', $e, ['author' => $authorName]);
// Build a warning with a clickable link to the existing thesis.
$existingUrl = htmlspecialchars('/admin/edit.php?id=' . $e->existingThesisId);
@@ -68,10 +68,8 @@ try {
'author' => $authorName,
'post_keys' => array_keys($_POST),
]);
error_log('ThesisCreateController error: ' . $e->getMessage());
App::flash('error', $e->getMessage());
ErrorHandler::log('thesis_create', $e, ['author' => $authorName]);
App::flash('error', ErrorHandler::userMessage($e));
$_SESSION['form_data'] = $_POST;
$redirect = '../add.php';

View File

@@ -25,6 +25,7 @@ if (!in_array($slug, $allowedSlugs, true)) {
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/AdminLogger.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
$db = new Database();
try {
@@ -32,8 +33,8 @@ try {
AdminLogger::make()->logPageEdit($slug);
App::flash('success', 'Page « ' . htmlspecialchars($slug) . ' » mise à jour.');
} catch (Exception $e) {
error_log('page save error: ' . $e->getMessage());
App::flash('error', 'Erreur lors de la sauvegarde : ' . htmlspecialchars($e->getMessage()));
ErrorHandler::log('page', $e);
App::flash('error', 'Erreur lors de la sauvegarde : ' . ErrorHandler::userMessage($e));
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

View File

@@ -6,6 +6,7 @@ AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
require_once __DIR__ . '/../../../src/ErrorHandler.php';
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
@@ -61,8 +62,8 @@ try {
}
} catch (Exception $e) {
error_log('publish.php error: ' . $e->getMessage());
App::flash('error', 'Erreur lors de la modification : ' . $e->getMessage());
ErrorHandler::log('thesis_publish', $e);
App::flash('error', 'Erreur lors de la modification : ' . ErrorHandler::userMessage($e));
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

View File

@@ -12,6 +12,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST'
require_once __DIR__ . '/../../../src/Database.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
require_once __DIR__ . '/../../../src/ErrorHandler.php';
try {
$db = new Database();
@@ -48,7 +49,8 @@ try {
App::flash('success', "Opération effectuée.");
} catch (Exception $e) {
App::flash('error', $e->getMessage());
ErrorHandler::log('tag', $e);
App::flash('error', ErrorHandler::userMessage($e));
}
header('Location: /admin/tags.php');

View File

@@ -12,6 +12,7 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
require_once __DIR__ . '/../../../src/Database.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
require_once __DIR__ . '/../../../src/ErrorHandler.php';
$action = $_POST['action'] ?? ''; // 'set_visibility'
$accessTypeId = filter_var($_POST['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
@@ -51,8 +52,8 @@ try {
App::flash('success', "Visibilité mise à jour.");
}
} catch (Exception $e) {
error_log("visibility.php error: " . $e->getMessage());
App::flash('error', "Erreur : " . $e->getMessage());
ErrorHandler::log('visibility', $e);
App::flash('error', 'Erreur : ' . ErrorHandler::userMessage($e));
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

View File

@@ -39,7 +39,9 @@ function withAutofocus(string $fieldName, array $attrs = []): array {
function old($key, $default = "") {
global $formData;
return isset($formData[$key]) ? htmlspecialchars($formData[$key]) : $default;
if (!isset($formData[$key])) return $default;
if (is_array($formData[$key])) return $formData[$key]; // Return raw array for callers that handle it
return htmlspecialchars($formData[$key]);
}
function wasSelected($key, $value) {

View File

@@ -23,7 +23,9 @@ $helpFn = fn(string $key) => empty($helpBlocks[$key]['enabled']) ? '' : ($helpBl
function old($key, $default = "") {
global $formData;
return isset($formData[$key]) ? htmlspecialchars($formData[$key]) : $default;
if (!isset($formData[$key])) return $default;
if (is_array($formData[$key])) return $formData[$key]; // Return raw array for callers that handle it
return htmlspecialchars($formData[$key]);
}
try {

View File

@@ -330,13 +330,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
}
}
if (!empty($keywordsRaw)) {
foreach (array_slice(array_map('trim', explode(',', $keywordsRaw)), 0, 10) as $kw) {
if ($kw) {
$tId = $importDb->findOrCreateTag($kw);
if ($tId) {
$s = $importPdo->prepare("INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)");
$s->execute([$thesisId, $tId]);
}
$normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
$tags = array_values(array_unique(array_map($normalizeTag, explode(',', $keywordsRaw))));
$tags = array_filter($tags, fn($t) => $t !== '');
foreach (array_slice($tags, 0, 10) as $kw) {
$tId = $importDb->findOrCreateTag($kw);
if ($tId) {
$s = $importPdo->prepare("INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)");
$s->execute([$thesisId, $tId]);
}
}
}

View File

@@ -0,0 +1,14 @@
<?php
/**
* tag-search-fragment.php (admin)
*
* HTMX fragment: returns matching tag suggestions for the mot-clé
* interactive search input. Admin-auth gated wrapper.
*/
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
App::boot();
require_once APP_ROOT . '/public/partage/tag-search-fragment.php';

View File

@@ -1006,3 +1006,216 @@ a.recap-file-name:hover {
.btn-secondary {
/* deprecated alias for .btn--secondary; kept for backward-compat */
}
/* ── Tag search (mots-clés interactive component) ──────────────────────── */
/* The outer container may be placed inside a fieldset or as a direct child
of .admin-form. Use a generic vertical layout that works in both contexts. */
[id$="-search-container"] {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
}
[id$="-search-container"] > .admin-row-label {
font-size: var(--step--1);
padding-top: 0;
font-weight: 400;
}
.tag-search-wrapper {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
position: relative;
}
/* Hint text above input */
.tag-search-hint {
display: block;
font-size: var(--step--2);
color: var(--text-secondary);
margin-bottom: var(--space-3xs);
}
/* Counter (e.g. "3/10") */
.tag-search-counter {
display: flex;
align-items: center;
gap: var(--space-2xs);
font-size: var(--step--2);
}
.tag-search-count {
font-weight: 600;
color: var(--accent-primary);
background: var(--accent-muted, rgba(149, 87, 181, 0.1));
padding: 1px var(--space-2xs);
border-radius: var(--radius);
}
.tag-search-max-msg {
color: var(--text-tertiary);
font-style: italic;
}
/* Pill row — spacing around selected tags area */
.tag-search-pills {
display: flex;
flex-wrap: wrap;
gap: var(--space-2xs);
min-height: 0;
padding: var(--space-2xs) 0;
}
.tag-search-pills:empty {
padding: 0;
display: none;
}
/* Individual pill */
.tag-pill {
display: inline-flex;
align-items: center;
gap: var(--space-3xs);
padding: 2px 2px 2px var(--space-2xs);
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 999px;
font-size: var(--step--1);
line-height: 1.4;
white-space: nowrap;
transition: border-color 0.15s;
}
.tag-pill:hover {
border-color: var(--accent-primary);
}
.tag-pill-name {
color: var(--text-primary);
}
/* Round remove button */
.tag-pill-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
}
.tag-pill-remove:hover {
background: var(--error);
color: #fff;
}
.tag-pill-remove svg {
width: 14px;
height: 14px;
}
/* Search input */
.tag-search-input-wrap {
display: flex;
}
.tag-search-input {
width: 100%;
font-size: var(--step--1);
font-family: inherit;
}
/* Suggestions dropdown — absolute positioned to hover above content */
.tag-search-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius);
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
max-height: 220px;
overflow-y: auto;
display: none;
margin-top: 2px;
}
.tag-search-suggestions:not(:empty) {
display: block;
}
.tag-search-empty {
padding: var(--space-2xs) var(--space-xs);
font-size: var(--step--2);
color: var(--text-tertiary);
pointer-events: none;
}
/* Individual suggestion item */
.tag-search-item {
display: flex;
align-items: center;
gap: var(--space-2xs);
width: 100%;
padding: var(--space-2xs) var(--space-xs);
background: transparent;
border: none;
border-bottom: 1px solid var(--border-primary);
font-family: inherit;
font-size: var(--step--1);
cursor: pointer;
text-align: left;
color: var(--text-primary);
transition: background 0.1s;
}
.tag-search-item:last-child {
border-bottom: none;
}
.tag-search-item:hover,
.tag-search-item:focus,
.tag-search-item--highlight {
background: var(--bg-secondary);
outline: none;
}
.tag-search-item--highlight {
background: var(--accent-muted, rgba(149, 87, 181, 0.1));
}
.tag-search-item-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-search-item-count {
font-size: var(--step--2);
color: var(--text-tertiary);
flex-shrink: 0;
}
.tag-search-item--create {
color: var(--accent-primary);
font-weight: 500;
border-bottom-style: dashed;
}
.tag-search-item--create:hover,
.tag-search-item--create.tag-search-item--highlight {
background: var(--accent-muted, rgba(149, 87, 181, 0.08));
}

View File

@@ -49,6 +49,13 @@ if ($slug === 'licence-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
// Special route: /partage/tag-search-fragment (HTMX fragment — interactive tag search)
if ($slug === 'tag-search-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST') {
App::boot();
require_once __DIR__ . '/tag-search-fragment.php';
exit;
}
// Special route: /partage/recapitulatif?id=N
if ($slug === 'recapitulatif' || $slug === 'recapitulatif.php') {
App::boot();
@@ -440,6 +447,7 @@ function handleShareLinkSubmission(string $slug): void
require_once APP_ROOT . '/src/StudentEmail.php';
require_once APP_ROOT . '/src/AppLogger.php';
require_once APP_ROOT . '/src/DuplicateThesisException.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
$logger = new AppLogger();
$authorName = $_POST['auteurice'] ?? 'unknown';
@@ -467,6 +475,7 @@ function handleShareLinkSubmission(string $slug): void
$emailSent = StudentEmail::sendConfirmation(Database::getInstance(), $thesisId, $_POST);
$_SESSION['share_email_sent'] = $emailSent;
} catch (SmtpSendException $e) {
ErrorHandler::log('partage_smtp', $e, ['slug' => $slug, 'thesis_id' => $thesisId]);
if ($e->isRecipientRejected()) {
$_SESSION['share_email_retry_thesis'] = $thesisId;
$_SESSION['share_email_retry_error'] = $e->smtpResponse;
@@ -485,8 +494,7 @@ function handleShareLinkSubmission(string $slug): void
$logger->logDuplicate('partage', $authorName, $e->existingThesisId, $e->existingIdentifier, [
'share_slug' => $slug,
]);
error_log('Share link duplicate submission: ' . $e->getMessage());
ErrorHandler::log('partage_duplicate', $e, ['slug' => $slug, 'author' => $authorName]);
// Repopulate the form and surface a clear warning to the student.
// Store as plain text — htmlspecialchars() is applied at render time.
@@ -505,10 +513,9 @@ function handleShareLinkSubmission(string $slug): void
'author' => $authorName,
'post_keys' => array_keys($_POST),
]);
ErrorHandler::log('partage_submit', $e, ['slug' => $slug, 'author' => $authorName]);
error_log('Share link submission error: ' . $e->getMessage());
$_SESSION['_flash_error'] = $e->getMessage();
$_SESSION['_flash_error'] = ErrorHandler::userMessage($e);
$_SESSION['form_data_share_' . $slug] = $_POST;
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token

View File

@@ -0,0 +1,67 @@
<?php
/**
* tag-search-fragment.php
*
* Shared HTMX fragment: returns matching tag suggestions for the mot-clé
* interactive search input.
*
* Included by:
* - /admin/tag-search-fragment.php (AdminAuth gated)
* - partage/index.php special route (public, session already booted)
*
* Expected POST:
* q — search query string (partial tag name)
* tag[] — already selected tag names (sent via hx-include, to exclude from suggestions)
*/
require_once __DIR__ . '/../../src/Database.php';
$query = trim(preg_replace('/\s+/', ' ', strtolower($_POST['q'] ?? '')));
$currentTags = isset($_POST['tag']) && is_array($_POST['tag'])
? array_map(function($t) { return trim(preg_replace('/\s+/', ' ', strtolower($t))); }, $_POST['tag'])
: [];
$db = Database::getInstance();
$results = $db->searchTags($query);
// Deduplicate results by lowercase name
$seen = [];
$results = array_values(array_filter($results, function($tag) use (&$seen) {
$key = strtolower($tag['name']);
if (isset($seen[$key])) return false;
$seen[$key] = true;
return true;
}));
// Filter out already-selected tags (case-insensitive)
$results = array_values(array_filter($results, function($tag) use ($currentTags) {
return !in_array(strtolower($tag['name']), $currentTags, true);
}));
// Check if query exactly matches an existing tag (case-insensitive)
$exactExists = false;
foreach ($results as $tag) {
if (strcasecmp($tag['name'], $query) === 0) {
$exactExists = true;
break;
}
}
// If no exact match and query non-empty, suggest creation
$canCreate = ($query !== '' && !$exactExists && !in_array($query, $currentTags, true));
?>
<?php if (empty($results) && !$canCreate): ?>
<div class="tag-search-empty">Aucun mot-clé trouvé.</div>
<?php endif; ?>
<?php foreach ($results as $tag): ?>
<button type="button" class="tag-search-item" data-tag-id="<?= (int)$tag['id'] ?>" data-tag-name="<?= htmlspecialchars($tag['name']) ?>">
<span class="tag-search-item-name"><?= htmlspecialchars($tag['name']) ?></span>
<span class="tag-search-item-count">(<?= (int)$tag['thesis_count'] ?>)</span>
</button>
<?php endforeach; ?>
<?php if ($canCreate): ?>
<button type="button" class="tag-search-item tag-search-item--create" data-tag-name="<?= htmlspecialchars($query) ?>">
<span class="tag-search-item-name">Créer «&nbsp;<?= htmlspecialchars($query) ?>&nbsp;»</span>
</button>
<?php endif; ?>