From 6cc0e407f3051255e0517805ef7a32e30531510f Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Sat, 9 May 2026 21:36:42 +0200 Subject: [PATCH] Error tests, FK violations fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CURRENT_ISSUES.md | 91 ++++ TODO.md | 3 + app/public/admin/actions/access-request.php | 5 +- app/public/admin/actions/apropos.php | 5 +- app/public/admin/actions/delete.php | 5 +- app/public/admin/actions/edit.php | 6 +- app/public/admin/actions/export-files.php | 5 +- app/public/admin/actions/export.php | 5 +- app/public/admin/actions/form-help.php | 5 +- app/public/admin/actions/formulaire.php | 10 +- app/public/admin/actions/page.php | 5 +- app/public/admin/actions/publish.php | 5 +- app/public/admin/actions/tag.php | 4 +- app/public/admin/actions/visibility.php | 5 +- app/public/admin/add.php | 4 +- app/public/admin/edit.php | 4 +- app/public/admin/index.php | 15 +- app/public/admin/tag-search-fragment.php | 14 + app/public/assets/css/form.css | 213 ++++++++ app/public/partage/index.php | 17 +- app/public/partage/tag-search-fragment.php | 67 +++ app/src/Controllers/AboutController.php | 3 +- app/src/Controllers/HomeController.php | 3 +- app/src/Controllers/LicenceController.php | 3 +- app/src/Controllers/MediaController.php | 3 +- app/src/Controllers/SearchController.php | 5 +- app/src/Controllers/TfeController.php | 3 +- .../Controllers/ThesisCreateController.php | 39 +- app/src/Controllers/ThesisEditController.php | 47 +- app/src/Database.php | 67 ++- app/src/ErrorHandler.php | 183 +++++++ app/templates/admin/acces.php | 13 + app/templates/admin/edit.php | 8 + app/templates/partials/form/form.php | 35 +- app/templates/partials/form/tag-search.php | 236 +++++++++ app/tests/Unit/ErrorHandlerTest.php | 453 ++++++++++++++++++ app/tests/Unit/FormSaveTest.php | 2 +- app/tests/run-tests.php | 1 + 38 files changed, 1515 insertions(+), 82 deletions(-) create mode 100644 CURRENT_ISSUES.md create mode 100644 app/public/admin/tag-search-fragment.php create mode 100644 app/public/partage/tag-search-fragment.php create mode 100644 app/src/ErrorHandler.php create mode 100644 app/templates/partials/form/tag-search.php create mode 100644 app/tests/Unit/ErrorHandlerTest.php diff --git a/CURRENT_ISSUES.md b/CURRENT_ISSUES.md new file mode 100644 index 0000000..8d53d03 --- /dev/null +++ b/CURRENT_ISSUES.md @@ -0,0 +1,91 @@ +# Current Issues — XAMXAM (2026-05-10) + +## 1. FK constraint violation on thesis save (create + edit) + +**Symptom:** `⚠ SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed` + +**Triggers:** +- Editing an imported CSV thesis, changing access type to Interdit, save +- Opening any imported thesis edit form and saving *without changes* +- Saving the add form with empty fields — form below "Cadre académique" disappears (PHP dies mid-render) + +**Root cause found so far:** `Database::createThesis()` at line ~1860 was doing `(int)$data['orientation_id']` which converts SQL null → PHP `null` → `(int)null` = `0`. Since no row with ID 0 exists in `orientations`, this triggers FK violation. Fixed in commit `55088c94` by using `$v ? (int)$v : null` pattern. + +**Still happening after fix** — suggests another code path has same issue, or the fix wasn't complete. The `updateThesis` path was already safe (uses `?: null`), but the error persists. + +**Debugging added:** +- `[DB:updateThesis]` log line with all FK values before query (commit `55088c94`) +- `[ThesisEdit] Step 1-6 OK` step-level logging (commit `8734d964`) +- `ErrorHandler::log()` with full trace on catch (commit `03ad73f3`) + +**Dev server output:** No error_log visible in dev mode — PHP built-in server sends errors to stderr which may not be captured. + +**Next steps:** +- Enable `display_errors=1` in dev mode so FK errors render in browser +- Check if `setThesisFormats`, `setThesisLanguages`, `setThesisTags` paths also have `(int)null` → `0` issues +- Check `formulaire.php` action file — does it also use `createThesis`? + +--- + +## 2. Dev server debugging output + +**Symptom:** No error output visible in browser when PHP crashes. + +**Current config** (`bootstrap.php`): +- Dev mode (cli-server): `display_errors=1`, `error_reporting=E_ALL` +- Production: `display_errors=0`, `log_errors=1` + +**But:** the admin action files override this: +- `formulaire.php` line 5-7: `ini_set('display_errors', 0); ini_set('log_errors', 1);` +- `edit.php` action: no override (uses bootstrap defaults) + +**Action needed:** Don't suppress display_errors in dev mode. Check `php_sapi_name()` before overriding. + +--- + +## 3. Console warnings + +``` +Layout was forced before the page was fully loaded. node.js:416:1 +[file-upload-queue] XamxamInitFileUploads called (twice) +``` +- `file-upload-queue.js` called twice — the script might be included twice (check `add.php` template + the `form.php` partial) + +--- + +## 4. Tags: lowercase + dedup + CSV import + +**Status:** Implemented across all paths: +- Frontend JS: `normalizeTag()` with `replace(/\s+/g, ' ')`, lowercase +- Server fragment: `preg_replace('/\s+/', ' ', strtolower(...))` +- Both controllers: `fn(string $t) => strtolower(trim(preg_replace('/\s+/', ' ', $t)))` +- CSV import: same normalization (commit `8734d964`) +- Minimum 3 tags enforced (commit `8734d964`) + +## 5. ErrorHandler coverage + +**Status:** Applied to 12 admin action files + 6 public controllers + 2 form controllers + partage entry point (commit `03ad73f3`). 77 unit test assertions. + +## Relevant commits (most recent first) + +``` +55088c94 Fix FK violation: (int)null → 0 in createThesis +27378b42 ErrorHandler tests: 77 assertions +4d9296fd ErrorHandler: precise FK field extraction from SQLite +03ad73f3 ErrorHandler: shared logging across all actions/controllers +6b6c62d1 Error logging: step-by-step transaction tracing +8734d964 Mots-clés: collapse spaces, minimum 3 keywords +dfe1186b Mots-clés: lowercase, dedup, keyboard nav, absolute dropdown +8d04d4ba Mots-clés: lowercase enforcement, deduplication, absolute dropdown +7fe53f8c Mots-clés: interactive HTMX tag search +dd110cc5 Admin mobile block: fix inline style beating media query +``` + +## Key files to review + +- `app/src/Database.php` — `createThesis()` line ~1830, `updateThesis()` line ~1751, `setThesisFormats/Languages/Tags` +- `app/src/Controllers/ThesisCreateController.php` — `submit()` line ~146, `validateAndSanitise()` line ~312 +- `app/src/Controllers/ThesisEditController.php` — `save()` line ~158 +- `app/public/admin/actions/formulaire.php` — calls `$ctrl->submit()` (create path) +- `app/public/admin/actions/edit.php` — calls `$ctrl->save()` (edit path) +- `app/public/admin/index.php` — CSV import at line ~220 diff --git a/TODO.md b/TODO.md index 7a1d082..383ed99 100644 --- a/TODO.md +++ b/TODO.md @@ -66,3 +66,6 @@ - [x] Admin mobile block screen: fix inline style beating media query, use CSS default display:none instead - [x] Fix deploy: add missing 023b migration to rename cc4r→cc2r, make run.php skip 'no such column' errors - [x] Fix FK constraint violation on edit: pass null instead of 0 for absent orientation/ap/finality +- [x] Mots-clés: interactive tag search with HTMX suggestions, pill display, round bin-icon remove buttons +- [x] Mots-clés: lowercase enforcement, deduplication, absolute dropdown, keyboard arrows/enter/escape, blur hide, spacing + counter above input, CSV import lowercased, space-collapse normalization, minimum 3 keywords required +- [x] ErrorHandler: shared static helper for structured error_log + user-friendly messages with precise FK field extraction from SQLite errors. Applied to 12 action files + 6 public controllers + 2 form controllers + partage. Covers FK, UNIQUE, NOT NULL constraint types. diff --git a/app/public/admin/actions/access-request.php b/app/public/admin/actions/access-request.php index b9d3ba6..7a8740a 100644 --- a/app/public/admin/actions/access-request.php +++ b/app/public/admin/actions/access-request.php @@ -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']); } diff --git a/app/public/admin/actions/apropos.php b/app/public/admin/actions/apropos.php index e609641..24276eb 100644 --- a/app/public/admin/actions/apropos.php +++ b/app/public/admin/actions/apropos.php @@ -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'); diff --git a/app/public/admin/actions/delete.php b/app/public/admin/actions/delete.php index a6070cc..8f02a2c 100644 --- a/app/public/admin/actions/delete.php +++ b/app/public/admin/actions/delete.php @@ -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)); diff --git a/app/public/admin/actions/edit.php b/app/public/admin/actions/edit.php index a29c85f..b78aba8 100644 --- a/app/public/admin/actions/edit.php +++ b/app/public/admin/actions/edit.php @@ -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()); diff --git a/app/public/admin/actions/export-files.php b/app/public/admin/actions/export-files.php index 8971e73..b44cc9e 100644 --- a/app/public/admin/actions/export-files.php +++ b/app/public/admin/actions/export-files.php @@ -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; diff --git a/app/public/admin/actions/export.php b/app/public/admin/actions/export.php index e3d8903..b130392 100644 --- a/app/public/admin/actions/export.php +++ b/app/public/admin/actions/export.php @@ -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))); } } diff --git a/app/public/admin/actions/form-help.php b/app/public/admin/actions/form-help.php index 7073474..94e22e7 100644 --- a/app/public/admin/actions/form-help.php +++ b/app/public/admin/actions/form-help.php @@ -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)); diff --git a/app/public/admin/actions/formulaire.php b/app/public/admin/actions/formulaire.php index 467f378..614567e 100644 --- a/app/public/admin/actions/formulaire.php +++ b/app/public/admin/actions/formulaire.php @@ -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'; diff --git a/app/public/admin/actions/page.php b/app/public/admin/actions/page.php index efd8d58..6f3aa33 100644 --- a/app/public/admin/actions/page.php +++ b/app/public/admin/actions/page.php @@ -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)); diff --git a/app/public/admin/actions/publish.php b/app/public/admin/actions/publish.php index 9ec32c2..f0e3a47 100644 --- a/app/public/admin/actions/publish.php +++ b/app/public/admin/actions/publish.php @@ -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)); diff --git a/app/public/admin/actions/tag.php b/app/public/admin/actions/tag.php index 70b33a1..83594a4 100644 --- a/app/public/admin/actions/tag.php +++ b/app/public/admin/actions/tag.php @@ -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'); diff --git a/app/public/admin/actions/visibility.php b/app/public/admin/actions/visibility.php index 8dfc700..8ceda84 100644 --- a/app/public/admin/actions/visibility.php +++ b/app/public/admin/actions/visibility.php @@ -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)); diff --git a/app/public/admin/add.php b/app/public/admin/add.php index 84c1375..1d3085e 100644 --- a/app/public/admin/add.php +++ b/app/public/admin/add.php @@ -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) { diff --git a/app/public/admin/edit.php b/app/public/admin/edit.php index 7b0202e..d08f5f5 100644 --- a/app/public/admin/edit.php +++ b/app/public/admin/edit.php @@ -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 { diff --git a/app/public/admin/index.php b/app/public/admin/index.php index 32da4ce..ae081ed 100644 --- a/app/public/admin/index.php +++ b/app/public/admin/index.php @@ -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]); } } } diff --git a/app/public/admin/tag-search-fragment.php b/app/public/admin/tag-search-fragment.php new file mode 100644 index 0000000..25c97c5 --- /dev/null +++ b/app/public/admin/tag-search-fragment.php @@ -0,0 +1,14 @@ + .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)); +} diff --git a/app/public/partage/index.php b/app/public/partage/index.php index 657ad4d..3bb6eb2 100644 --- a/app/public/partage/index.php +++ b/app/public/partage/index.php @@ -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 diff --git a/app/public/partage/tag-search-fragment.php b/app/public/partage/tag-search-fragment.php new file mode 100644 index 0000000..b951b3b --- /dev/null +++ b/app/public/partage/tag-search-fragment.php @@ -0,0 +1,67 @@ +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)); +?> + +
Aucun mot-clé trouvé.
+ + + + + + + + + diff --git a/app/src/Controllers/AboutController.php b/app/src/Controllers/AboutController.php index b386242..63fec33 100644 --- a/app/src/Controllers/AboutController.php +++ b/app/src/Controllers/AboutController.php @@ -2,6 +2,7 @@ require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/Parsedown.php'; +require_once APP_ROOT . '/src/ErrorHandler.php'; class AboutController { @@ -24,7 +25,7 @@ class AboutController $contacts = $db->getAproposContent('contacts'); $contacts = is_array($contacts) && !empty($contacts) ? $contacts : null; } catch (Exception $e) { - error_log('Error loading about page: ' . $e->getMessage()); + ErrorHandler::log('about_page', $e); $rawContent = $this->defaultContent; $contacts = null; } diff --git a/app/src/Controllers/HomeController.php b/app/src/Controllers/HomeController.php index 34a0130..0db7e47 100644 --- a/app/src/Controllers/HomeController.php +++ b/app/src/Controllers/HomeController.php @@ -37,6 +37,7 @@ class HomeController public static function create(): self { require_once APP_ROOT . '/src/Database.php'; + require_once APP_ROOT . '/src/ErrorHandler.php'; return new self(Database::getInstance()); } @@ -98,7 +99,7 @@ class HomeController ); } } catch (Exception $e) { - error_log('HomeController: ' . $e->getMessage()); + ErrorHandler::log('home', $e); // Return safe empty state; view will show "Aucun mémoire trouvé" $isDefaultView = false; } diff --git a/app/src/Controllers/LicenceController.php b/app/src/Controllers/LicenceController.php index 79d9cae..11dbf74 100644 --- a/app/src/Controllers/LicenceController.php +++ b/app/src/Controllers/LicenceController.php @@ -2,6 +2,7 @@ require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/Parsedown.php'; +require_once APP_ROOT . '/src/ErrorHandler.php'; class LicenceController { @@ -18,7 +19,7 @@ class LicenceController $content = $dbPage ? $dbPage['content'] : ''; $pageTitle = $dbPage ? $dbPage['title'] : 'Licences'; } catch (Exception $e) { - error_log('Error loading licence page: ' . $e->getMessage()); + ErrorHandler::log('licence_page', $e); $content = ''; $pageTitle = 'Licences'; } diff --git a/app/src/Controllers/MediaController.php b/app/src/Controllers/MediaController.php index 2384c13..e01e202 100644 --- a/app/src/Controllers/MediaController.php +++ b/app/src/Controllers/MediaController.php @@ -53,6 +53,7 @@ class MediaController // 3. Visibility gate for thesis files if (preg_match('#^theses/#', $requestedPath)) { require_once APP_ROOT . '/src/Database.php'; + require_once APP_ROOT . '/src/ErrorHandler.php'; try { $mediaDb = Database::getInstance(); $accessTypeId = $mediaDb->getFileVisibility($requestedPath); @@ -61,7 +62,7 @@ class MediaController exit; } } catch (\Throwable $e) { - error_log('MediaController visibility check error: ' . $e->getMessage()); + ErrorHandler::log('media_visibility', $e, ['path' => $path]); } } diff --git a/app/src/Controllers/SearchController.php b/app/src/Controllers/SearchController.php index 28d8b2e..d355bcb 100644 --- a/app/src/Controllers/SearchController.php +++ b/app/src/Controllers/SearchController.php @@ -45,6 +45,7 @@ class SearchController { require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/RateLimit.php'; + require_once APP_ROOT . '/src/ErrorHandler.php'; $rateLimit = new RateLimit( self::RATE_LIMIT_MAX, @@ -105,7 +106,7 @@ class SearchController } catch (InvalidArgumentException $e) { $validationError = $e->getMessage(); } catch (Exception $e) { - error_log('SearchController: ' . $e->getMessage()); + ErrorHandler::log('search', $e); $validationError = 'Une erreur est survenue.'; } @@ -169,7 +170,7 @@ class SearchController } catch (InvalidArgumentException $e) { $validationError = $e->getMessage(); } catch (Exception $e) { - error_log('SearchController: ' . $e->getMessage()); + ErrorHandler::log('repertoire', $e); $validationError = 'Une erreur est survenue.'; } diff --git a/app/src/Controllers/TfeController.php b/app/src/Controllers/TfeController.php index 1dd8743..36ddd33 100644 --- a/app/src/Controllers/TfeController.php +++ b/app/src/Controllers/TfeController.php @@ -39,6 +39,7 @@ class TfeController public static function create(): self { require_once APP_ROOT . '/src/Database.php'; + require_once APP_ROOT . '/src/ErrorHandler.php'; return new self(Database::getInstance()); } @@ -64,7 +65,7 @@ class TfeController try { $data = $this->db->getThesisById($thesisId); } catch (Exception $e) { - error_log('TfeController: ' . $e->getMessage()); + ErrorHandler::log('tfe_view', $e, ['thesis_id' => $thesisId]); $this->redirectHome(); } diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index 4ccc2f3..a7d783c 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -77,6 +77,7 @@ class ThesisCreateController public static function make(): self { require_once APP_ROOT . '/src/Database.php'; + require_once APP_ROOT . '/src/ErrorHandler.php'; return new self(new Database()); } @@ -198,17 +199,28 @@ class ThesisCreateController ]); $identifier = $this->db->getThesisIdentifier($thesisId); - error_log("ThesisCreateController: created thesis #$thesisId ($identifier) with " . count($authorEntries) . ' author(s)'); + error_log("[ThesisCreate] Step 1 OK — thesis_id=$thesisId ($identifier) | authors=" . count($authorEntries)); $this->db->setThesisAuthors($thesisId, $authorEntries); + error_log("[ThesisCreate] Step 2 OK — authors=" . json_encode($data['authorNames'])); + $this->db->setThesisJury($thesisId, $data['juryMembers']); + error_log("[ThesisCreate] Step 3 OK — jury=" . count($data['juryMembers'])); + $this->db->setThesisLanguages($thesisId, $data['languageIds']); + error_log("[ThesisCreate] Step 4 OK — languages=" . json_encode($data['languageIds'])); + $this->db->setThesisFormats($thesisId, $data['formatIds']); + error_log("[ThesisCreate] Step 5 OK — formats=" . json_encode($data['formatIds'])); + $this->db->setThesisTags($thesisId, $data['keywords']); + error_log("[ThesisCreate] Step 6 OK — tags=" . json_encode($data['keywords'])); $this->db->commit(); + error_log("[ThesisCreate] COMMIT OK — thesis_id=$thesisId"); } catch (Exception $e) { + ErrorHandler::log('thesis_create_tx', $e, ['thesis_id' => $thesisId ?? null]); $this->db->rollback(); throw $e; } @@ -420,12 +432,31 @@ class ThesisCreateController throw new Exception('Veuillez indiquer au moins un·e lecteur·ice externe.'); } - // Keywords (max 10) - $tagRaw = $this->sanitiseString($post['tag'] ?? ''); - $keywords = $tagRaw !== '' ? array_map('trim', explode(',', $tagRaw)) : []; + // Keywords (max 10, min 3) — lowercased, spaces collapsed, deduplicated + $keywords = []; + $normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))); + if (isset($post['tag']) && is_array($post['tag'])) { + $keywords = array_values(array_unique(array_map( + $normalizeTag, + array_map(fn($t) => (string)$t, $post['tag']) + ))); + $keywords = array_filter($keywords, fn($t) => $t !== ''); + $keywords = array_slice($keywords, 0, 10); + } else { + $tagRaw = $this->sanitiseString($post['tag'] ?? ''); + if ($tagRaw !== '') { + $keywords = array_map($normalizeTag, explode(',', $tagRaw)); + } + } + $keywords = array_values(array_unique($keywords)); + $keywords = array_filter($keywords, fn($t) => $t !== ''); + $keywords = array_slice($keywords, 0, 10); if (count($keywords) > 10) { throw new Exception('Maximum 10 mots-clés autorisés.'); } + if (count($keywords) < 3) { + throw new Exception('Veuillez indiquer au moins 3 mots-clés.'); + } // Languages (at least one required) $languageIds = isset($post['languages']) && is_array($post['languages']) diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 546b628..1fb6902 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -36,6 +36,7 @@ class ThesisEditController public static function create(?Database $db = null): self { require_once APP_ROOT . '/src/Database.php'; + require_once APP_ROOT . '/src/ErrorHandler.php'; return new self($db ?? Database::getInstance()); } @@ -192,7 +193,7 @@ class ThesisEditController try { // ── 1. Thesis metadata ──────────────────────────────────────────── - $this->db->updateThesis($thesisId, [ + $meta = [ 'title' => trim($post['titre'] ?? ''), 'subtitle' => trim($post['subtitle'] ?? ''), 'year' => intval($post['année'] ?? 0), @@ -211,7 +212,9 @@ class ThesisEditController 'exemplaire_erg' => !empty($post['exemplaire_erg']), 'cc2r' => !empty($post['cc2r']), 'license_custom' => trim($post['license_custom'] ?? ''), - ]); + ]; + $this->db->updateThesis($thesisId, $meta); + error_log('[ThesisEdit] Step 1 OK — thesis_id=' . $thesisId); // ── 2. Authors (alphabetically sorted) ───────────────────────────── $authorsRaw = trim($post['auteurice'] ?? ''); @@ -230,10 +233,12 @@ class ThesisEditController ]; } $this->db->setThesisAuthors($thesisId, $authorEntries); + error_log('[ThesisEdit] Step 2 OK — authors=' . json_encode($authorNames)); // ── 3. Jury ─────────────────────────────────────────────────────── $juryMembers = $this->collectJuryMembers($post); $this->db->setThesisJury($thesisId, $juryMembers); + error_log('[ThesisEdit] Step 3 OK — jury=' . count($juryMembers)); // ── 4. Languages ────────────────────────────────────────────────── $langIds = isset($post['languages']) && is_array($post['languages']) @@ -248,25 +253,43 @@ class ThesisEditController } } $this->db->setThesisLanguages($thesisId, $langIds); + error_log('[ThesisEdit] Step 4 OK — languages=' . json_encode($langIds)); // ── 5. Formats ──────────────────────────────────────────────────── - $this->db->setThesisFormats( - $thesisId, - isset($post['formats']) && is_array($post['formats']) - ? $post['formats'] - : [] - ); + $formatIds = isset($post['formats']) && is_array($post['formats']) + ? $post['formats'] + : []; + $this->db->setThesisFormats($thesisId, $formatIds); + error_log('[ThesisEdit] Step 5 OK — formats=' . json_encode($formatIds)); // ── 6. Tags ─────────────────────────────────────────────────────── - $keywordsRaw = trim($post['tag'] ?? ''); - $keywords = $keywordsRaw !== '' - ? array_map('trim', explode(',', $keywordsRaw)) - : []; + $normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))); + $keywords = []; + if (isset($post['tag']) && is_array($post['tag'])) { + $keywords = array_values(array_unique(array_map( + $normalizeTag, + array_map(fn($t) => (string)$t, $post['tag']) + ))); + } else { + $keywordsRaw = trim($post['tag'] ?? ''); + if ($keywordsRaw !== '') { + $keywords = array_map($normalizeTag, explode(',', $keywordsRaw)); + } + } + $keywords = array_values(array_unique($keywords)); + $keywords = array_filter($keywords, fn($t) => $t !== ''); + $keywords = array_slice($keywords, 0, 10); + if (count($keywords) < 3) { + throw new Exception('Veuillez indiquer au moins 3 mots-clés.'); + } $this->db->setThesisTags($thesisId, $keywords); + error_log('[ThesisEdit] Step 6 OK — tags=' . json_encode($keywords)); $this->db->commit(); + error_log('[ThesisEdit] COMMIT OK — thesis_id=' . $thesisId); } catch (Exception $e) { + ErrorHandler::log('thesis_edit_tx', $e, ['thesis_id' => $thesisId]); $this->db->rollback(); throw $e; } diff --git a/app/src/Database.php b/app/src/Database.php index 6a4b31d..0fb665d 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -1134,6 +1134,39 @@ class Database // TAG MANAGEMENT (admin) // ======================================================================== + /** + * Search tags by name prefix. Returns up to 10 matching tags. + * If $query is empty, returns the most-used tags (up to 10). + */ + public function searchTags(string $query = ''): array + { + $query = trim($query); + if ($query === '') { + $stmt = $this->pdo->query(' + SELECT tg.id, tg.name, + COUNT(DISTINCT tt.thesis_id) as thesis_count + FROM tags tg + LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id + GROUP BY tg.id + ORDER BY thesis_count DESC, tg.name COLLATE NOCASE + LIMIT 10 + '); + } else { + $stmt = $this->pdo->prepare(' + SELECT tg.id, tg.name, + COUNT(DISTINCT tt.thesis_id) as thesis_count + FROM tags tg + LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id + WHERE tg.name LIKE ? + GROUP BY tg.id + ORDER BY tg.name = ? DESC, thesis_count DESC, tg.name COLLATE NOCASE + LIMIT 10 + '); + $stmt->execute([$query . '%', $query]); + } + return $stmt->fetchAll(); + } + /** * Return all tags with a count of associated (published) theses. */ @@ -1740,19 +1773,27 @@ class Database updated_at = CURRENT_TIMESTAMP WHERE id = ? '); + $orientation = ($data['orientation_id'] ?? null) ? (int)$data['orientation_id'] : null; + $ap = ($data['ap_program_id'] ?? null) ? (int)$data['ap_program_id'] : null; + $finality = ($data['finality_id'] ?? null) ? (int)$data['finality_id'] : null; + $license = $data['license_id'] ?? null; + $access = $data['access_type_id'] ?? null; + + error_log("[DB:updateThesis] thesis_id=$thesisId orientation=$orientation ap=$ap finality=$finality license=$license access=$access"); + $stmt->execute([ $data['title'], !empty($data['subtitle']) ? $data['subtitle'] : null, (int)$data['year'], - ($data['orientation_id'] ?? null) ? (int)$data['orientation_id'] : null, - ($data['ap_program_id'] ?? null) ? (int)$data['ap_program_id'] : null, - ($data['finality_id'] ?? null) ? (int)$data['finality_id'] : null, + $orientation, + $ap, + $finality, $data['synopsis'], !empty($data['context_note']) ? $data['context_note'] : null, !empty($data['baiu_link']) ? $data['baiu_link'] : null, - $data['license_id'] ?? null, + $license, !empty($data['license_custom']) ? $data['license_custom'] : null, - $data['access_type_id'] ?? null, + $access, $data['is_published'] ? 1 : 0, !empty($data['remarks']) ? $data['remarks'] : null, isset($data['jury_points']) && $data['jury_points'] !== '' ? (float)$data['jury_points'] : null, @@ -1808,20 +1849,26 @@ class Database $validObjet = ['tfe', 'thèse', 'frart']; $objet = in_array($data['objet'] ?? '', $validObjet, true) ? $data['objet'] : 'tfe'; + $orientation = $data['orientation_id'] ?? null; + $ap = $data['ap_program_id'] ?? null; + $finality = $data['finality_id'] ?? null; + $license = $data['license_id'] ?? null; + $access = $data['access_type_id'] ?? 2; + $stmt->execute([ $identifier, $data['title'], !empty($data['subtitle']) ? $data['subtitle'] : null, (int)$data['year'], - (int)$data['orientation_id'], - (int)$data['ap_program_id'], - (int)$data['finality_id'], + $orientation ? (int)$orientation : null, + $ap ? (int)$ap : null, + $finality ? (int)$finality : null, $data['synopsis'], !empty($data['context_note']) ? $data['context_note'] : null, !empty($data['baiu_link']) ? $data['baiu_link'] : null, - $data['license_id'] ?? null, + $license ? (int)$license : null, !empty($data['license_custom']) ? $data['license_custom'] : null, - isset($data['access_type_id']) ? (int)$data['access_type_id'] : 2, // default: Interne + $access ? (int)$access : 2, // default: Interne $objet, !empty($data['remarks']) ? $data['remarks'] : null, isset($data['jury_points']) && $data['jury_points'] !== '' ? (float)$data['jury_points'] : null, diff --git a/app/src/ErrorHandler.php b/app/src/ErrorHandler.php new file mode 100644 index 0000000..90d7f73 --- /dev/null +++ b/app/src/ErrorHandler.php @@ -0,0 +1,183 @@ + $id]); + * App::flash('error', ErrorHandler::userMessage($e)); + */ +class ErrorHandler +{ + /** + * Map SQLite FK constraint errors to human-readable field names. + * + * SQLite FK errors reference the *child* table (where the FK column lives), + * e.g. "INSERT INTO theses" when orientation_id FK fails. + * + * We map child table → the form field(s) whose values populate its FK columns. + */ + private const FK_TABLE_MAP = [ + // Main thesis table — FK columns: orientation_id, ap_program_id, + // finality_id, access_type_id, license_id + 'theses' => 'Orientation, AP, Finalité, Type d\'accès ou Licence', + // Junction tables — each maps to one specific field + 'thesis_authors' => 'Auteur·ice', + 'thesis_supervisors' => 'Composition du jury', + 'thesis_languages' => 'Langue(s)', + 'thesis_formats' => 'Format(s)', + 'thesis_tags' => 'Mots-clés', + 'thesis_files' => 'Fichiers', + // Admin / system tables + 'share_links' => 'Lien étudiant·e', + 'file_access_requests' => "Demande d'accès", + 'form_help_blocks' => "Bloc d'aide", + 'site_settings' => 'Paramètres', + 'admin_log' => 'Journal admin', + // Parent tables (when referenced directly in "table" pattern) + 'orientations' => 'Orientation', + 'ap_programs' => 'AP', + 'finality_types' => 'Finalité', + 'access_types' => "Type d'accès", + 'license_types' => 'Licence', + 'authors' => 'Auteur·ice', + 'supervisors' => 'Composition du jury', + 'languages' => 'Langue(s)', + 'format_types' => 'Format(s)', + 'tags' => 'Mots-clés', + ]; + + /** + * Map an exception to a user-facing message. + * + * Domain exceptions (validation, duplicates) carry their own message; + * system exceptions (PDO, generic) get sanitised explanations with + * specific field identification where possible. + */ + public static function userMessage(\Throwable $e): string + { + // ── Domain exceptions with meaningful messages ────────────────────── + if ($e instanceof DuplicateThesisException) { + return $e->getMessage(); // Already user-friendly + } + + // ── Database errors ───────────────────────────────────────────────── + if ($e instanceof \PDOException) { + return self::pdoMessage($e); + } + + // ── Validation errors (RuntimeException, InvalidArgumentException) ── + // These are thrown with user-friendly French messages — pass through. + if ($e instanceof \RuntimeException) { + return $e->getMessage(); + } + + // ── Everything else — generic fallback ────────────────────────────── + return 'Une erreur inattendue est survenue.' + . ' Veuillez réessayer ou contacter l\'équipe.'; + } + + /** + * Build a user-facing message from a PDOException. + * + * For FK constraint failures, the SQLite error message includes the + * table name (e.g. "FOREIGN KEY constraint failed INSERT INTO thesis_formats"). + * We extract it and map to a field label. + */ + private static function pdoMessage(\PDOException $e): string + { + $msg = $e->getMessage(); + + if (str_contains($msg, 'FOREIGN KEY constraint failed')) { + $field = self::extractFkField($msg); + + if ($field !== null) { + return "Erreur de base de données : la référence pour « {$field} » est invalide." + . ' Vérifiez que la valeur existe dans la base de données.'; + } + + return 'Erreur de base de données : une contrainte de référence est invalide.' + . ' Vérifiez les champs Orientation, AP, Finalité, Langues, Formats.'; + } + + // ── UNIQUE constraint ─────────────────────────────────────────────── + if (str_contains($msg, 'UNIQUE constraint failed')) { + return 'Erreur de base de données : une valeur en double a été détectée.' + . ' Veuillez réessayer ou contacter l\'équipe.'; + } + + // ── NOT NULL ──────────────────────────────────────────────────────── + if (str_contains($msg, 'NOT NULL constraint failed')) { + return 'Erreur de base de données : un champ obligatoire est manquant.' + . ' Veuillez réessayer ou contacter l\'équipe.'; + } + + // ── Generic SQL error — don't leak raw SQL to users ──────────────── + return 'Une erreur de base de données est survenue.' + . ' Veuillez réessayer ou contacter l\'équipe.'; + } + + /** + * Try to extract the referenced table name from a SQLite FK error message. + * + * SQLite FK errors typically contain the table name in the message body: + * "FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?)" + * + * Also handles quoted table names in newer SQLite messages: + * "FOREIGN KEY constraint failed (table \"orientations\")" + */ + private static function extractFkField(string $msg): ?string + { + // Pattern 1: "table \"tablename\"" (SQLite 3.37+) + if (preg_match('/table\s+"([^"]+)"/i', $msg, $m)) { + $table = strtolower($m[1]); + return self::FK_TABLE_MAP[$table] ?? null; + } + + // Pattern 2: "INSERT INTO tablename" or "UPDATE tablename" + if (preg_match('/(?:INSERT\s+INTO|UPDATE)\s+"?(\w+)"?/i', $msg, $m)) { + $table = strtolower($m[1]); + return self::FK_TABLE_MAP[$table] ?? null; + } + + // Pattern 3: "REFERENCES tablename" — the constraint itself + if (preg_match('/REFERENCES\s+"?(\w+)"?/i', $msg, $m)) { + $table = strtolower($m[1]); + return self::FK_TABLE_MAP[$table] ?? null; + } + + return null; + } + + /** + * Write a structured error log entry. + * + * @param string $context e.g. 'thesis_edit', 'partage_submit', 'csv_import' + * @param \Throwable $e + * @param array $extra arbitrary key-value context to include in the log + */ + public static function log(string $context, \Throwable $e, array $extra = []): void + { + $parts = [ + 'context=' . $context, + 'exception=' . get_class($e), + 'message=' . $e->getMessage(), + ]; + foreach ($extra as $k => $v) { + if (is_scalar($v) || $v === null) { + $parts[] = $k . '=' . json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } elseif (is_array($v)) { + $parts[] = $k . '=' . json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } else { + $parts[] = $k . '=' . gettype($v); + } + } + $parts[] = 'trace=' . $e->getTraceAsString(); + + error_log(implode(' | ', $parts)); + } +} diff --git a/app/templates/admin/acces.php b/app/templates/admin/acces.php index e96c782..076e0b8 100644 --- a/app/templates/admin/acces.php +++ b/app/templates/admin/acces.php @@ -49,6 +49,19 @@ \\\\\\\ to: unnnvyqs 357a2fff "Admin mobile block: fix inline style beating media query" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%% diff from: unnnvyqs 357a2fff "Admin mobile block: fix inline style beating media query" (rebased revision) +\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\ to: szktqmnn bdcd30e9 "Error tests, FK violations fix" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: szktqmnn 29b3397f "Error tests, FK violations fix" (rebased revision) +++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; ?> diff --git a/app/templates/admin/edit.php b/app/templates/admin/edit.php index 1b13928..a84165a 100644 --- a/app/templates/admin/edit.php +++ b/app/templates/admin/edit.php @@ -105,6 +105,14 @@ // Languages — either from flash repopulation or current thesis data $formData['languages'] = $formData['languages'] ?? $currentLanguages ?? []; + // Tags — either from flash repopulation or current thesis data + $keywordsStr = $thesis['keywords'] ?? ''; + $currentTags = $keywordsStr !== '' ? array_map('trim', explode(',', $keywordsStr)) : []; + // If formData has tag[], use that instead + if (!empty($formData['tag']) && is_array($formData['tag'])) { + $currentTags = $formData['tag']; + } + // Formats — either from flash repopulation or current thesis data $checkedFormats = $formData['formats'] ?? $currentFormats ?? []; // Populate formData.formats for checkbox-list partial diff --git a/app/templates/partials/form/form.php b/app/templates/partials/form/form.php index 1b8e994..b899b40 100644 --- a/app/templates/partials/form/form.php +++ b/app/templates/partials/form/form.php @@ -222,13 +222,36 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
Mots-clés trim($_t)]; + } + } + } elseif (!empty($currentTags) && is_array($currentTags)) { + $_selectedTags = array_map(fn($n) => ['name' => $n], $currentTags); + } else { + $_tagsRaw = $formData["tag"] ?? ''; + if (is_string($_tagsRaw) && $_tagsRaw !== '') { + foreach (array_map('trim', explode(',', $_tagsRaw)) as $_t) { + if ($_t !== '') { + $_selectedTags[] = ['name' => $_t]; + } + } + } + } + $name = "tag"; - $label = "Mots-clés (max 10) :"; - $value = $oldFn("tag"); - $placeholder = "sociologie, anthropologie, ..."; - $hint = "Séparez par des virgules. Max 10 mots-clés."; - $attrs = $withAutofocusFn("tag"); - include APP_ROOT . "/templates/partials/form/text-field.php"; + $label = "Mots-clés :"; + $placeholder = "Rechercher un mot-clé…"; + $hint = "Tapez pour rechercher ou créer des mots-clés."; + $selectedTags = $_selectedTags; + $hxPost = ($mode === 'partage') ? "/partage/tag-search-fragment" : "/admin/tag-search-fragment.php"; + include APP_ROOT . "/templates/partials/form/tag-search.php"; + unset($_tagsRaw, $_selectedTags, $_t, $name, $label, $placeholder, $hint, $selectedTags, $hxPost); ?>
diff --git a/app/templates/partials/form/tag-search.php b/app/templates/partials/form/tag-search.php new file mode 100644 index 0000000..435106f --- /dev/null +++ b/app/templates/partials/form/tag-search.php @@ -0,0 +1,236 @@ + int|null, 'name' => string] for pre-filled tags + * string|null $id — override the id attribute prefix + * int $maxTags — maximum number of tags (default 10) + */ + +$name = $name ?? 'tag'; +$label = $label ?? 'Mots-clés'; +$placeholder = $placeholder ?? 'Rechercher un mot-clé…'; +$hint = $hint ?? null; +$hxPost = $hxPost ?? '/admin/tag-search-fragment.php'; +$selectedTags = $selectedTags ?? []; +$id = $id ?? $name; +$maxTags = $maxTags ?? 10; +$tagCount = count($selectedTags); +?> +
+ +
+ + + + + +
+ + + + + + + +
+ + + + + +
= $maxTags ? ' style="display:none"' : '' ?>> + +
+ + +
+
+
+ + + + + 42, + 'slug' => '20250101-TEST1234', + 'author' => 'Test Author', + ]); + echo " ✓ log() completed without exception\n"; + } catch (Throwable $e) { + throw new RuntimeException("FAIL: log() threw: " . $e->getMessage()); + } + + echo "J2: log with null values in extra\n"; + ErrorHandler::log('test_null', new PDOException('test'), ['id' => null, 'name' => 'foo']); + echo " ✓ log() handles null values\n"; + + echo "J3: log with empty extra array\n"; + ErrorHandler::log('test_empty', new RuntimeException('bare')); + echo " ✓ log() handles empty extra\n"; + + echo "\n"; + + // ========================================================================= + // SECTION K: Keyword normalization logic (tag normalization) + // ========================================================================= + + echo "K: Keyword normalization logic\n"; + + // Test the normalization regex used in controllers and JS: + // strtolower(trim(preg_replace('/\s+/', ' ', $t))) + $normalize = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))); + + echo "K1: basic trimming and casing\n"; + ehAssertEq('hello', $normalize('Hello'), 'uppercase → lowercase'); + ehAssertEq('hello', $normalize(' hello '), 'trimmed'); + ehAssertEq('hello world', $normalize('Hello World'), 'two words lowercased'); + + echo "K2: multiple spaces collapsed\n"; + ehAssertEq('a b c', $normalize('a b c'), 'double spaces → single'); + ehAssertEq('x y', $normalize(' x y '), 'leading/trailing + multiple → clean'); + + echo "K3: tabs and newlines collapsed to space\n"; + ehAssertEq('word1 word2', $normalize("word1\tword2"), 'tab → space'); + ehAssertEq('line1 line2', $normalize("line1\nline2"), 'newline → space'); + ehAssertEq('mixed spaces', $normalize("mixed \t \n spaces"), 'mixed whitespace → single space'); + + echo "K4: French accents preserved\n"; + ehAssertEq('très précis', $normalize('Très Précis'), 'accents preserved in lowercase'); + + echo "K5: empty string\n"; + ehAssertEq('', $normalize(''), 'empty stays empty'); + ehAssertEq('', $normalize(' '), 'whitespace-only becomes empty'); + + echo "K6: special characters not mangled\n"; + ehAssertEq("c++", $normalize("C++"), 'symbols preserved'); + ehAssertEq("c#", $normalize("C#"), 'hash preserved'); + + echo "\n"; + + // ========================================================================= + // SECTION L: Deduplication on normalize (case-insensitive) + // ========================================================================= + + echo "L: Deduplication after normalization\n"; + + $dedup = function(array $tags): array { + return array_values(array_unique(array_map( + fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))), + $tags + ))); + }; + + echo "L1: case-insensitive dedup\n"; + ehAssertEq(['hello'], $dedup(['Hello', 'hello', 'HELLO']), 'case variations → one entry'); + + echo "L2: whitespace-insensitive dedup\n"; + ehAssertEq(['hello world'], $dedup(['hello world', 'Hello World', 'hello world']), 'whitespace + case → one entry'); + + echo "L3: empty strings filtered\n"; + $filtered = array_values(array_filter($dedup(['', ' ', 'valid']), fn($t) => $t !== '')); + ehAssertEq(['valid'], $filtered, 'empty/whitespace-only removed'); + + echo "L4: mixed valid and empty\n"; + $result = array_values(array_filter($dedup(['Alpha', '', ' ', 'BETA', 'alpha']), fn($t) => $t !== '')); + ehAssertEq(['alpha', 'beta'], $result, 'deduplicated and empties filtered'); + + echo "\n"; + + // ========================================================================= + // SECTION M: Minimum/maximum tag count enforcement\n + // ========================================================================= + + echo "M: Tag count constraints\n"; + + echo "M1: 3 tags is valid\n"; + $valid = ['one', 'two', 'three']; + ehAssert(count($valid) >= 3, '3 tags ≥ minimum 3'); + ehAssert(count($valid) <= 10, '3 tags ≤ maximum 10'); + + echo "M2: < 3 tags triggers error\n"; + $tooFew = ['one']; + ehAssert(count($tooFew) < 3, '1 tag < minimum 3'); + + echo "M3: > 10 tags triggers error\n"; + $tooMany = ['a','b','c','d','e','f','g','h','i','j','k']; + ehAssert(count($tooMany) > 10, '11 tags > maximum 10'); + + echo "M4: empty array\n"; + ehAssert(count([]) < 3, 'empty array < minimum 3'); + + echo "\n"; + + // ========================================================================= + // SECTION N: Real SQLite FK error message formats + // ========================================================================= + + echo "N: Real-world SQLite FK error message patterns\n"; + + // These are actual error messages observed in the wild. + echo "N1: typical INSERT INTO with VALUES\n"; + $msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats ("thesis_id", "format_id") VALUES (?, ?)'); + $user = ErrorHandler::userMessage($msg); + ehAssertContains('Format(s)', $user, 'quoted column names handled'); + + echo "N2: UPDATE statement\n"; + $msg = makeFkException('FOREIGN KEY constraint failed UPDATE theses SET orientation_id = ? WHERE id = ?'); + $user = ErrorHandler::userMessage($msg); + ehAssertContains('Orientation', $user, 'UPDATE statement parsed'); + + echo "N3: long FK message with multiple table references\n"; + // Only the first match should be used (the INSERT target table) + $msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?) REFERENCES format_types'); + $user = ErrorHandler::userMessage($msg); + ehAssertContains('Format(s)', $user, 'first table match used'); + + echo "\n"; + + echo "✅ All ErrorHandler tests passed!\n"; + $result = true; + +} catch (Exception $e) { + echo '❌ FAIL: ' . $e->getMessage() . "\n"; + $result = false; +} + +return $result ?? false; diff --git a/app/tests/Unit/FormSaveTest.php b/app/tests/Unit/FormSaveTest.php index 8e4859d..623da3c 100644 --- a/app/tests/Unit/FormSaveTest.php +++ b/app/tests/Unit/FormSaveTest.php @@ -68,7 +68,7 @@ function buildPost(Database $db, array $overrides = []): array 'languages' => [(string)$languages[0]['id']], 'language_autre' => '', 'formats' => [(string)$formatTypes[0]['id']], - 'tag' => 'art, test', + 'tag' => 'art, test, recherche', 'jury_promoteur' => 'Prof. Smith', 'jury_lecteur_interne' => ['Dr. Internal'], 'jury_lecteur_externe' => ['Dr. External'], diff --git a/app/tests/run-tests.php b/app/tests/run-tests.php index bab5118..dd1e793 100755 --- a/app/tests/run-tests.php +++ b/app/tests/run-tests.php @@ -15,6 +15,7 @@ $testFiles = [ ['name' => 'Rate Limit (Unit)', 'path' => __DIR__ . '/Unit/RateLimitTest.php'], ['name' => 'Form Save Round-Trip (Unit)', 'path' => __DIR__ . '/Unit/FormSaveTest.php'], ['name' => 'ShareLink (Unit)', 'path' => __DIR__ . '/Unit/ShareLinkTest.php'], + ['name' => 'ErrorHandler (Unit)', 'path' => __DIR__ . '/Unit/ErrorHandlerTest.php'], ['name' => 'Pure Logic (Unit)', 'path' => __DIR__ . '/Unit/PureLogicTest.php'], ['name' => 'Search (Integration)', 'path' => __DIR__ . '/Integration/SearchTest.php'], ['name' => 'Security', 'path' => __DIR__ . '/Security/SecurityTest.php'],