Files
xamxam/TODO.md

18 KiB

TODO

Styling Redesign (matching design images)

  • Redesign shared nav bar (purple gradient top, flat, POSTERG / RÉPERTOIRE / À PROPOS)
  • Redesign shared search bar (full-width, icon, bottom border only, white bg)
  • Rewrite common.css (nav + search bar components)
  • Rewrite main.css (home page — white bg, media card grid, label below)
  • Rewrite search.css (répertoire index — 4-col ANNÉES/CATÉGORIES/ÉTUDIANTES/MOTS-CLÉS)
  • Rewrite tfe.css (TFE page — 2-col, large author/title left, media right)
  • Add apropos.css (À Propos — 2-col, large monospace text)
  • Rewrite admin.css (dark bg, purple gradient nav, bottom-border-only form inputs)
  • Update templates/nav.php (new shared nav partial)
  • Update templates/search-bar.php (new shared search bar partial)
  • Rewrite public/index.php (home page with new layout)
  • Rewrite public/search.php (répertoire index view + search results view)
  • Rewrite public/tfe.php (individual TFE page)
  • Create public/apropos.php (À Propos page)
  • Rewrite templates/admin/head.php (admin nav)
  • Rewrite templates/admin/footer.php (clean close)
  • Rewrite public/admin/add.php (form with row layout)
  • Rewrite public/admin/index.php (dark table)
  • Rewrite public/admin/edit.php (form with row layout)
  • Rewrite public/admin/login.php (centered dark login box)
  • Rewrite public/admin/thanks.php (dark info cards)
  • Rewrite public/admin/import.php (clean dark form)

Justfile / Ops

  • Simplify serve and deploy to one recipe each
  • Remove sysadmin recipes (server-logs, server-status, deploy-nginx, deploy-admin-tools)
  • Extract server scripts to scripts/ (deploy-server.sh, manage-admin-users.sh)
  • Guard deploy-db against overwriting existing remote database
  • Update README.md and docs/SERVER_SETUP.md to reflect current structure

Refactor: M2M tags via tags + thesis_tags junction table

The current schema stores keywords in a keywords table joined via thesis_keywords. The field column is named keyword (not name), breaking the naming convention used by every other lookup table (orientations.name, format_types.name, etc.). More critically, buildSearchConditions and the view v_theses_full filter keywords through GROUP_CONCAT strings with LIKE, bypassing the junction table entirely.

Goal: rename the tables and column to the canonical M2M pattern (tags, thesis_tags, tags.name), add the missing index, and rewrite all tag queries to use a proper JOIN.

1 — Schema migration (storage/schema.sql + live DB)

  • Rename table keywordstags; rename column keywords.keywordtags.name
  • Rename junction table thesis_keywordsthesis_tags; rename FK column thesis_keywords.keyword_idthesis_tags.tag_id
  • Composite PK on thesis_tags(tag_id, thesis_id) (tag first — matches the lookup pattern WHERE t.name = ?)
  • Add index idx_tags_name ON tags(name) (supports exact-match lookup on insert/find)
  • Update idx_thesis_keywords_* index names → idx_thesis_tags_thesis, idx_thesis_tags_tag
  • Update view v_theses_full / v_theses_public: replace LEFT JOIN keywords k ON tk.keyword_id = k.id … GROUP_CONCAT(DISTINCT k.keyword) with LEFT JOIN tags t ON tt.tag_id = t.id … GROUP_CONCAT(DISTINCT t.name)
  • Write and test a SQLite migration script (storage/migrations/001_rename_keywords_to_tags.sql)

2 — src/Database.php

  • findOrCreateKeyword()findOrCreateTag(): query tags table, column name
  • getUsedKeywords()getUsedTags(): rewrite to use proper M2M JOIN instead of querying the view: sql SELECT DISTINCT t.* FROM tags t JOIN thesis_tags tt ON t.id = tt.tag_id JOIN theses th ON tt.thesis_id = th.id WHERE th.is_published = 1 ORDER BY t.name
  • buildSearchConditions: replace the keywords LIKE :keyword view-string hack with a subquery using the junction table: sql EXISTS ( SELECT 1 FROM thesis_tags tt JOIN tags t ON t.id = tt.tag_id WHERE tt.thesis_id = theses.id AND t.name LIKE :keyword ESCAPE '\' ) (search still runs on v_theses_public; the subquery references the base table)
  • validateSearchParams: rename key 'keyword''tag' (or keep alias for backwards-compat during transition)
  • Add backwards-compat alias findOrCreateKeywordfindOrCreateTag and getUsedKeywordsgetUsedTags (remove after all callers updated)

3 — Admin write paths

  • public/admin/actions/formulaire.php: replace findOrCreateKeyword + INSERT INTO thesis_keywords with findOrCreateTag + INSERT INTO thesis_tags
  • public/admin/edit.php: same replacement in keyword update block (DELETE FROM thesis_keywordsDELETE FROM thesis_tags, insert loop)

4 — Public read paths

  • public/search.php: rename $keywords$tags; update getUsedKeywords() call → getUsedTags(); rename GET param keywordtag (keep old param as alias)
  • public/tfe.php: $data['keywords']$data['tags'] (view column rename)
  • templates/search-bar.php (if applicable): update any hardcoded keyword param refs

5 — Admin tag management UI (/admin/tags.php)

The goal is a dedicated page for viewing, renaming, merging, and deleting tags, with full referential-integrity awareness (no orphan thesis_tags rows, no broken search results).

5a — src/Database.php — new tag-management methods

  • getAllTagsWithCount(): array — return all tags with a thesis_count column: sql SELECT t.id, t.name, COUNT(tt.thesis_id) AS thesis_count FROM tags t LEFT JOIN thesis_tags tt ON t.id = tt.tag_id GROUP BY t.id ORDER BY t.name
  • renameTag(int $id, string $newName): void — UPDATE tags SET name = ? WHERE id = ?; unique-constraint violation must be caught and re-thrown as a user-readable InvalidArgumentException
  • mergeTag(int $sourceId, int $targetId): void — within a transaction: 1. INSERT OR IGNORE into thesis_tags(tag_id, thesis_id) for every row in thesis_tags WHERE tag_id = $sourceId (re-point to target, skip dupes) 2. DELETE FROM thesis_tags WHERE tag_id = $sourceId 3. DELETE FROM tags WHERE id = $sourceId
  • deleteTag(int $id): void — within a transaction: DELETE FROM thesis_tags WHERE tag_id = ? first (FK cascade may handle this after the schema migration, but an explicit delete is safer), then DELETE FROM tags WHERE id = ?; reject with exception if thesis_count > 0 and the caller did not pass $force = true

5b — public/admin/tags.php — list + inline-edit view

  • Auth guard: AdminAuth::requireLogin() at top; CSRF token in session
  • Page title: "Gestion des mots-clés" (reuses $pageTitle for head.php)
  • Load getAllTagsWithCount() for display
  • Table columns: Mot-clé (editable inline) · Nb de TFE · Actions
  • Each row has:
    • An inline <form> (POST to actions/tag.php, action=rename) pre-filled with the current tag name, so the admin can edit in-place and submit per row
    • A Fusionner → button that reveals a <select> of other tags and a confirm submit (POST action=merge, fields: source_id, target_id)
    • A Supprimer button — disabled / greyed if thesis_count > 0; otherwise submits POST action=delete, field: tag_id; requires JS confirm() before submit
  • Flash success/error messages from $_SESSION['success'] / $_SESSION['error'] (same pattern as index.php)
  • Empty state: "Aucun mot-clé enregistré." if table is empty
  • Stats bar (reuse .admin-stats CSS): total tag count, total TFE with ≥1 tag, total tags with 0 TFE (orphan count)

5c — public/admin/actions/tag.php — POST action handler

  • Auth guard + CSRF check (same pattern as publish.php)
  • Route on $_POST['action']:
    • 'rename': validate tag_id (int > 0) and name (non-empty, max 100 chars, trimmed); call renameTag(); set $_SESSION['success']
    • 'merge': validate source_id and target_id are distinct positive ints; call mergeTag(); set success message including both tag names
    • 'delete': validate tag_id; call deleteTag(); set success message
  • On any exception: set $_SESSION['error']
  • Regenerate CSRF token after every action
  • Redirect back to /admin/tags.php in all cases

5d — Nav & routing

  • templates/admin/head.php: add nav link <a href="/admin/tags.php" …>Mots-clés</a> between "Ajouter un TFE" and "Importer une liste de TFE"; apply active class on tags.php
  • nginx/posterg.conf (or equivalent): if using try_files / location blocks, confirm /admin/tags.php and /admin/actions/tag.php are reachable (likely already covered by the existing *.php rule, but verify)

5e — Propagation safety checklist

These must remain unbroken after the tag-management UI is live:

  • Renaming a tag updates tags.name; v_theses_full reads from tags directly, so GROUP_CONCAT output updates automatically — no view rebuild needed
  • Renaming a tag does not affect thesis_tags rows (only the name column changes); search results remain intact
  • Merging tag A→B: all theses that had tag A now appear under tag B; the EXISTS subquery in buildSearchConditions finds them via thesis_tags
  • Deleting a tag with thesis_count > 0 is blocked by default (explicit $force parameter required); the UI never shows a delete button for in-use tags — double protection
  • edit.php keyword field renders the current CSV of tag names from $thesis['keywords'] (view column); after a rename the edit page will show the new name on next load — no extra work needed
  • The public search.php keyword filter ($_GET['tag']) uses tag names; after a rename, any bookmarked URL with the old name will return 0 results (expected behaviour — no redirect needed, but note it in a code comment)

6 — Tests

  • tests/Unit/DatabaseTest.php: add test for findOrCreateTag round-trip
  • tests/Integration/SearchTest.php: add test for tag-filter search using the new subquery path (not the LIKE-on-GROUP_CONCAT path)

6 — Fixtures / seed data

  • storage/fixtures/CreateTestDatabase.php: update to use tags / thesis_tags table names and findOrCreateTag()

Feature: Mode Maintenance

Adds "Déployer" / "Maintenance" buttons in admin. When maintenance mode is on, the public site is unavailable (503) while the admin section keeps working.

Implementation

  • Storage — store maintenance flag as a file on disk (storage/maintenance.flag) so it requires no DB and survives restarts; flag presence = maintenance mode ON
  • Public gate — add a check at the top of every public-facing PHP entry-point (public/index.php, public/search.php, public/tfe.php, public/media.php, public/apropos.php) via a shared helper in config/bootstrap.php: checkMaintenanceMode() — if flag exists, render a 503 maintenance page and exit
  • Maintenance page — create public/maintenance.php (503 HTML page, styled minimally, not imported via bootstrap so it never recurses)
  • Admin action — create public/admin/actions/maintenance.php (POST handler): CSRF-validated, two actions: enable (touch flag file) and disable (unlink flag)
  • Admin UI — add a "Site en ligne / Maintenance" status bar in public/admin/index.php with "Déployer" (→ disable) and "Maintenance" (→ enable) buttons; display current state clearly (green = en ligne, orange = maintenance)
  • Admin exemption — admin pages (/admin/*) must NOT be gated; the maintenance check must only run on public routes; admin bootstrap already bypasses it by not calling checkMaintenanceMode()
  • src/config.php — add MAINTENANCE_FLAG_PATH constant pointing to storage/maintenance.flag

Feature: TFE Visibility States (publique / interne / interdit)

Replaces the binary is_published with a three-state visibility field on each TFE, controlling what the public site exposes. Default: interne.

States

State DB value Public page: metadata Public page: files Note displayed
publique public ✓ full ✓ via media.php
interne intern ✓ full ✗ hidden "Copies physiques disponibles aux archives de l'école."
interdit forbid ✓ full ✗ hidden context_note from DB (jury note, editable in admin)

Schema

  • DB migrationstorage/migrations/002_add_visibility.sql: - ALTER TABLE theses ADD COLUMN visibility TEXT NOT NULL DEFAULT 'intern' - UPDATE theses SET visibility = 'public' WHERE is_published = 1 (migrate existing data) - update v_theses_full view to expose visibility (DROP + CREATE in migration) - update v_theses_public view: change filter from is_published = 1 to is_published = 1 (keep; is_published stays for "show in listings" gate) - Note: is_published keeps its meaning (appears in public listings at all); visibility controls what is accessible once found; a TFE can be is_published = 1 + visibility = 'intern' → appears in list, files hidden
  • storage/schema.sql — add visibility column + update views + add idx_theses_visibility index
  • storage/fixtures/CreateTestDatabase.php — seed some TFEs with each visibility state (default: intern)

Backend

  • src/Database.php: - getThesisById($id) — already used by tfe.php; ensure it returns visibility and context_note - getThesis($id) (admin) — already returns all columns; ensure visibility and context_note pass through - getThesesList() — add visibility to SELECT for admin list display - setVisibility(int $thesisId, string $state): void — UPDATE theses SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?; validate $state['public', 'intern', 'forbid'] - bulkSetVisibility(array $ids, string $state): void — batch UPDATE for the bulk-action bar

Public site

  • public/media.php — add visibility check: 1. Load thesis_files row by path to get thesis_id 2. Load theses.visibility for that thesis_id (one extra query) 3. If visibility !== 'public' → return 403 (This is the critical security gate; all other changes are UX)
  • public/tfe.php — conditional rendering based on $data['visibility']: - publique: render files normally (existing logic) - interne: skip files block; show notice "Les copies physiques de ce travail sont disponibles dans les archives de l'école." - interdit: skip files block; show $data['context_note'] (jury note) if set, otherwise a generic access-restricted message
  • public/index.php / public/search.php — no change needed (thumbnail logic: if visibility !== 'public', skip thumbnail even if file exists; adjust the thumbnail-lookup block in index.php)

Admin UI

  • public/admin/edit.php: - Replace the current is_published checkbox with a <select name="visibility"> with three labelled options (Publique / Interne / Interdit) - Keep is_published checkbox separately (controls listing visibility) - Add <textarea name="context_note"> for jury note (shown only when visibility = interdit, via JS toggle, but always submitted so admin can pre-fill before switching state) - Update the POST handler to save visibility and context_note via UPDATE theses SET visibility = ?, context_note = ? …
  • public/admin/index.php: - Update status badge column: show three-state badge instead of binary "Publié / En attente": - publique → green "Publique" - interne → blue "Interne" - interdit → red "Interdit" - Update stats bar: counts for each state instead of published/pending - Add bulk-action buttons for visibility: "Rendre publique", "Rendre interne", "Rendre interdit" (replaces or supplements existing publish/unpublish) - Update JS bulkAction() to support new action names
  • public/admin/actions/publish.php — extend to handle new visibility actions: set_public, set_intern, set_forbid for single and bulk modes; keep existing publish/unpublish actions for is_published toggle; alternatively rename/create public/admin/actions/visibility.php as a dedicated handler and keep publish.php for is_published only
  • public/admin/thanks.php — add "Visibilité" row to the info display

Pending

  • Add flake.nix for Nix-based PHP dev environment
  • Add favicon (<link rel="icon"> → admin_favicon.svg) to all pages; nginx 204 for /favicon.ico
  • Add pagination to répertoire student index (currently capped at 100)
  • Thumbnail generation / cover image support for home grid cards

Admin / Server

  • Create scripts/setup-server.sh (one-time server setup: group, ownership, setgid 2775 on dirs)
  • Add just setup-server recipe (rsync + run setup-server.sh on remote)
  • Exclude .claude and .pi from rsync deploy
  • Update docs/SERVER_SETUP.md with correct permissions rationale and troubleshooting
  • Add server status view in admin panel (nginx + php-fpm health, site HTTP check)
  • Add server log viewer in admin panel (tail nginx error/access logs via SSH or log endpoint)
  • Add nginx config deploy flow to admin panel (upload scripts/deploy-server.sh, run remotely)
  • Add admin user management UI (wraps scripts/manage-admin-users.sh on server)