Files
xamxam/docs/cms-migration-plan.html
Pontoporeia 13d26ded66 Replace HTMX+PHP file upload queues with client-side JS
Drops the session-backed HTMX incremental upload system in favour of a
single JS module that manages `File` objects client-side and injects
them into `FormData` on submit.

Key changes:

* `file-upload-queue.js`: client-side queues with validation, reorder
  (SortableJS), removal, dirty-state tracking, and fetch-based submit
  with manual redirect handling
* `fichiers-fragment.php`: empty queue containers for JS-managed queues;
  HTMX format switching still works with queue rehydration after swap;
  annexe uploads now support multiple files
* Form UI cleanup: moved existing files and cover preview into the
  `Fichiers` fieldset (edit mode); removed redundant queue labels while
  keeping labels for single-file inputs (`couverture`,
  `note d'intention`); added delete buttons for existing files
* `ThesisFileHandler.php`: added
  `handleTfeQueueFiles()`/`handleAnnexeQueueFiles()` reading from
  `$_FILES['queue_file']`; introduced `extractFilesSubArray()` for
  nested upload arrays; removed session-based queue handling
* `ThesisCreateController.php` &
  `ThesisEditController.php`: switched to extracted
  `['queue_file']` uploads
* `beforeunload-guard.js`: now also watches
  `window.__xamxamDirty`
* Deleted obsolete PHP upload/remove/reorder queue endpoints for
  `partage` and `admin`
* Cleaned up route dispatch in `partage/index.php`
* Misc form and styling updates in templates/CSS
* Added `docs/cms-migration-plan.html`
2026-05-19 00:08:05 +02:00

1267 lines
60 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XAMXAM — CMS Migration Plan</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f8f7f4;
--surface: #ffffff;
--border: #e2dfd9;
--text: #1a1a1a;
--muted: #6b6b6b;
--accent: #2563eb;
--accent-bg: #eff6ff;
--warn: #d97706;
--warn-bg: #fffbeb;
--danger: #dc2626;
--danger-bg: #fef2f2;
--ok: #16a34a;
--ok-bg: #f0fdf4;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04);
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.65;
font-size: 15px;
}
/* ── Layout ── */
.page-header {
background: var(--text);
color: #fff;
padding: 3rem 2rem 2.5rem;
}
.page-header h1 { font-size: 2rem; font-weight: 700; letter-spacing: -.03em; margin-bottom: .4rem; }
.page-header .subtitle { color: #aaa; font-size: .95rem; }
.page-header .meta { margin-top: 1rem; display: flex; gap: 1.5rem; flex-wrap: wrap; font-size: .85rem; color: #888; }
.page-header .meta span strong { color: #ccc; }
.toc {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: .75rem 2rem;
font-size: .85rem;
display: flex; gap: 1rem; flex-wrap: wrap;
position: sticky; top: 0; z-index: 10;
}
.toc a { color: var(--accent); text-decoration: none; white-space: nowrap; }
.toc a:hover { text-decoration: underline; }
main { max-width: 1060px; margin: 0 auto; padding: 2.5rem 2rem 5rem; }
section { margin-bottom: 3.5rem; }
section:first-child { margin-top: .5rem; }
h2 {
font-size: 1.35rem; font-weight: 700; letter-spacing: -.02em;
border-bottom: 2px solid var(--border);
padding-bottom: .5rem; margin-bottom: 1.5rem;
scroll-margin-top: 60px;
}
h3 { font-size: 1.05rem; font-weight: 600; margin: 1.5rem 0 .6rem; }
h4 { font-size: .9rem; font-weight: 600; margin: 1rem 0 .4rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
p { margin-bottom: .8rem; }
ul, ol { padding-left: 1.4rem; margin-bottom: .8rem; }
li { margin-bottom: .35rem; }
/* ── Cards / callouts ── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
box-shadow: var(--shadow);
margin-bottom: 1rem;
}
.callout {
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin-bottom: 1rem;
border-left: 4px solid;
}
.callout.info { background: var(--accent-bg); border-color: var(--accent); }
.callout.warn { background: var(--warn-bg); border-color: var(--warn); }
.callout.danger{ background: var(--danger-bg); border-color: var(--danger); }
.callout.ok { background: var(--ok-bg); border-color: var(--ok); }
.callout-title { font-weight: 700; margin-bottom: .3rem; font-size: .85rem; text-transform: uppercase; letter-spacing: .06em; }
.callout.info .callout-title { color: var(--accent); }
.callout.warn .callout-title { color: var(--warn); }
.callout.danger .callout-title { color: var(--danger); }
.callout.ok .callout-title { color: var(--ok); }
/* ── CMS comparison grid ── */
.cms-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.cms-card {
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
position: relative;
}
.cms-card.recommended {
border-color: var(--accent);
}
.badge {
display: inline-block;
font-size: .7rem; font-weight: 700; letter-spacing: .06em;
text-transform: uppercase; padding: .2rem .55rem;
border-radius: 99px; margin-bottom: .75rem;
}
.badge.recommended { background: var(--accent); color: #fff; }
.badge.alt { background: #e5e7eb; color: #374151; }
.badge.not-suitable{ background: #fee2e2; color: #991b1b; }
.cms-card h3 { margin-top: 0; font-size: 1.1rem; }
.cms-card .tagline { color: var(--muted); font-size: .85rem; margin-bottom: .75rem; }
.pros-cons { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem 1rem; margin-top: .75rem; }
.pros-cons h4 { margin: 0 0 .3rem; font-size: .78rem; }
.pros-cons ul { font-size: .82rem; padding-left: 1.1rem; margin: 0; }
.pros-cons li { margin-bottom: .25rem; }
/* ── Feature mapping table ── */
.feature-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
.feature-table th {
background: #f1f0ed; text-align: left;
padding: .55rem .85rem; font-size: .78rem; font-weight: 600;
text-transform: uppercase; letter-spacing: .05em;
border-bottom: 2px solid var(--border);
}
.feature-table td {
padding: .55rem .85rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.feature-table tr:last-child td { border-bottom: none; }
.feature-table tr:hover td { background: #fafafa; }
.tag {
display: inline-block; font-size: .72rem; font-weight: 600;
padding: .15rem .45rem; border-radius: 4px; white-space: nowrap;
}
.tag.native { background: var(--ok-bg); color: var(--ok); }
.tag.plugin { background: var(--accent-bg); color: var(--accent); }
.tag.custom { background: var(--warn-bg); color: var(--warn); }
.tag.drop { background: var(--danger-bg); color: var(--danger); }
/* ── Timeline ── */
.timeline { position: relative; padding-left: 2rem; }
.timeline::before {
content: ''; position: absolute; left: .55rem; top: .5rem; bottom: 0;
width: 2px; background: var(--border);
}
.phase {
position: relative; margin-bottom: 1.75rem;
}
.phase::before {
content: ''; position: absolute; left: -1.67rem; top: .45rem;
width: 12px; height: 12px; border-radius: 50%;
border: 2px solid var(--accent); background: var(--surface);
}
.phase-header { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .4rem; }
.phase-num { font-size: .75rem; font-weight: 700; text-transform: uppercase; color: var(--accent); letter-spacing: .07em; }
.phase-duration { font-size: .8rem; color: var(--muted); }
.phase h3 { font-size: 1rem; margin: 0; }
.phase ul { font-size: .88rem; margin-top: .4rem; }
/* ── Effort table ── */
.effort-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
.effort-table th {
background: #f1f0ed; text-align: left;
padding: .5rem .85rem; font-size: .78rem; font-weight: 600;
text-transform: uppercase; letter-spacing: .05em;
border-bottom: 2px solid var(--border);
}
.effort-table td { padding: .5rem .85rem; border-bottom: 1px solid var(--border); }
.effort-table tfoot td { font-weight: 700; background: #f8f7f4; }
.effort-table .bar-cell { width: 140px; }
.bar-wrap { background: #e5e7eb; border-radius: 99px; height: 8px; }
.bar { height: 8px; border-radius: 99px; background: var(--accent); }
/* ── Architecture diagram (ASCII) ── */
.arch-diagram {
background: #1a1a2e; color: #a8d8ea;
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
font-size: .8rem; line-height: 1.55;
border-radius: var(--radius); padding: 1.5rem 2rem;
overflow-x: auto; white-space: pre;
}
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 640px) { .two-col { grid-template-columns: 1fr; } .pros-cons { grid-template-columns: 1fr; } }
/* ── Invoice ── */
.invoice {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
font-size: .88rem;
}
.invoice-header {
background: var(--text); color: #fff;
padding: 1.5rem 2rem;
display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap;
}
.invoice-header .from { font-size: .8rem; color: #aaa; line-height: 1.7; }
.invoice-header .from strong { color: #fff; display: block; font-size: 1rem; margin-bottom: .2rem; }
.invoice-header .inv-meta { text-align: right; font-size: .8rem; color: #aaa; line-height: 1.7; }
.invoice-header .inv-meta strong { color: #fff; display: block; font-size: 1rem; margin-bottom: .2rem; }
.invoice-to { padding: 1.25rem 2rem; border-bottom: 1px solid var(--border); background: #fafaf8; }
.invoice-to .label { font-size: .72rem; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: .3rem; }
.invoice-to p { margin: 0; line-height: 1.6; color: var(--text); }
.invoice-lines { width: 100%; border-collapse: collapse; }
.invoice-lines th {
background: #f1f0ed; text-align: left;
padding: .55rem 1.25rem; font-size: .75rem; font-weight: 600;
text-transform: uppercase; letter-spacing: .06em;
border-bottom: 2px solid var(--border);
}
.invoice-lines th.num { text-align: right; }
.invoice-lines td { padding: .6rem 1.25rem; border-bottom: 1px solid var(--border); vertical-align: top; }
.invoice-lines td.num { text-align: right; white-space: nowrap; }
.invoice-lines tr:last-child td { border-bottom: none; }
.invoice-lines tr.section-head td {
background: #f8f7f4; font-weight: 600; font-size: .78rem;
text-transform: uppercase; letter-spacing: .05em; color: var(--muted);
padding: .4rem 1.25rem;
}
.invoice-totals { border-top: 2px solid var(--border); }
.invoice-totals table { width: 100%; border-collapse: collapse; }
.invoice-totals td { padding: .5rem 1.25rem; }
.invoice-totals td:first-child { color: var(--muted); }
.invoice-totals td:last-child { text-align: right; font-weight: 600; white-space: nowrap; }
.invoice-totals tr.grand td { background: #1a1a1a; color: #fff; font-size: 1rem; font-weight: 700; }
.invoice-totals tr.grand td:last-child { color: #fff; }
.invoice-footer { padding: 1rem 1.25rem; color: var(--muted); font-size: .78rem; border-top: 1px solid var(--border); line-height: 1.7; }
code { font-family: monospace; background: #f1f0ed; padding: .1em .4em; border-radius: 3px; font-size: .88em; }
strong { font-weight: 600; }
</style>
</head>
<body>
<header class="page-header">
<h1>XAMXAM — CMS Migration Plan</h1>
<p class="subtitle">Implementation plan for rebuilding the ERG thesis repository on a commercially available CMS</p>
<div class="meta">
<span><strong>Date</strong> May 2026</span>
<span><strong>Scope</strong> Full feature-equivalent rebuild</span>
<span><strong>Recommended stack</strong> Strapi v5 + Next.js 15</span>
<span><strong>Estimated timeline</strong> 1824 weeks</span>
</div>
</header>
<nav class="toc">
<strong style="color:#888;font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;align-self:center">Jump to →</strong>
<a href="#overview">Overview</a>
<a href="#cms-choice">CMS Choice</a>
<a href="#architecture">Architecture</a>
<a href="#features">Feature Map</a>
<a href="#requirements">Requirements</a>
<a href="#timeline">Timeline</a>
<a href="#effort">Effort &amp; Cost</a>
<a href="#invoice">Invoice example</a>
<a href="#tradeoffs">Trade-offs</a>
<a href="#not-included">Not included</a>
</nav>
<main>
<!-- ─────────────────────────────────── OVERVIEW ─────────────────────────────────── -->
<section id="overview">
<h2>1. Project Overview</h2>
<p>
<strong>XAMXAM</strong> (formerly Posterg) is the thesis/TFE (<em>Travail de Fin d'Études</em>) repository
for the <strong>ERG — École de Recherche Graphique</strong> in Brussels. It is a purpose-built PHP 8.4 +
SQLite3 application deployed behind nginx.
</p>
<div class="two-col">
<div class="card">
<h3>Current tech stack</h3>
<ul>
<li>PHP 8.4, SQLite 3, nginx</li>
<li>Custom MVC (no framework)</li>
<li>HTMX for partial rendering</li>
<li>No npm / no build step</li>
<li>rsync deployment (no git on server)</li>
<li>Single-server, single-file database</li>
</ul>
</div>
<div class="card">
<h3>What the system does</h3>
<ul>
<li>Public browseable + searchable thesis catalogue</li>
<li>Admin panel: create / edit / publish / export theses</li>
<li>Student submission via one-time share links</li>
<li>Controlled file serving (PDFs, images, video, audio)</li>
<li>PeerTube video upload integration</li>
<li>SMTP email notifications with encrypted credentials</li>
<li>Audit log, maintenance mode, CSV/DB export</li>
<li>Access control: open / restricted (cookie token) / forbidden</li>
</ul>
</div>
</div>
<div class="callout info">
<div class="callout-title">Scope of this plan</div>
The goal is a <strong>feature-equivalent rebuild</strong> using an off-the-shelf CMS — meaning the ERG
team would no longer need to maintain bespoke PHP controllers, database migrations, or a custom admin panel.
Custom code is minimised to integrations that no CMS ships out-of-the-box (share links, restricted
file access, PeerTube upload).
</div>
</section>
<!-- ──────────────────────────────── CMS CHOICE ─────────────────────────────────── -->
<section id="cms-choice">
<h2>2. CMS Choice</h2>
<p>Three commercially available CMS products were evaluated against XAMXAM's requirements.</p>
<div class="cms-grid">
<div class="cms-card recommended">
<span class="badge recommended">Recommended</span>
<h3>Strapi v5</h3>
<p class="tagline">Headless Node.js CMS — self-hosted, open-source core</p>
<div class="pros-cons">
<div>
<h4>Pros</h4>
<ul>
<li>Rich relation fields (authors, jury, files, tags) built-in</li>
<li>Admin panel generated from content-type schema</li>
<li>Role-based access control (admin vs. student roles)</li>
<li>Media library with upload providers</li>
<li>REST + GraphQL API out-of-the-box</li>
<li>Lifecycle hooks for custom logic</li>
<li>Plugin system for custom routes</li>
<li>SQLite supported (small team, single server)</li>
</ul>
</div>
<div>
<h4>Cons</h4>
<ul>
<li>JavaScript/Node.js — different from current PHP stack</li>
<li>Separate frontend required (Next.js or similar)</li>
<li>Share-link submission portal = custom plugin</li>
<li>File-access tokens = custom plugin</li>
<li>PeerTube upload = custom plugin</li>
<li>More moving parts than current single-PHP-server setup</li>
</ul>
</div>
</div>
</div>
<div class="cms-card">
<span class="badge alt">Alternative</span>
<h3>Kirby CMS 4</h3>
<p class="tagline">Flat-file PHP CMS — familiar language, no database required</p>
<div class="pros-cons">
<div>
<h4>Pros</h4>
<ul>
<li>PHP — same language as today</li>
<li>Flexible content blueprints</li>
<li>Custom routes and hooks</li>
<li>Panel (admin UI) with custom sections</li>
<li>Lightweight, no extra runtime</li>
</ul>
</div>
<div>
<h4>Cons</h4>
<ul>
<li>Flat-file (YAML/Markdown) — poor fit for relational thesis data</li>
<li>No built-in user roles for student submission</li>
<li>Search, pagination, and filtering need custom code</li>
<li>Virtually all the complex logic still needs writing</li>
<li>Comercial licence required ($199/site)</li>
</ul>
</div>
</div>
</div>
<div class="cms-card">
<span class="badge not-suitable">Not suitable</span>
<h3>WordPress</h3>
<p class="tagline">PHP CMS + plugin ecosystem</p>
<div class="pros-cons">
<div>
<h4>Pros</h4>
<ul>
<li>Familiar to many users</li>
<li>Large plugin ecosystem</li>
</ul>
</div>
<div>
<h4>Cons</h4>
<ul>
<li>Content model too blog-centric for thesis metadata</li>
<li>Custom post types + ACF = heavyweight approach</li>
<li>Student submission portal not in any standard plugin</li>
<li>Security attack surface is substantial</li>
<li>Performance tuning overhead for file serving</li>
</ul>
</div>
</div>
</div>
</div>
<div class="callout ok">
<div class="callout-title">Verdict</div>
<strong>Strapi v5</strong> is the best match. Its content-type system maps naturally to the thesis data
model (relations, media, nested metadata). The admin panel is generated automatically from the schema.
Custom plugins cover the three features not provided by any CMS out-of-the-box: share links, restricted
file access, and PeerTube upload.
</div>
</section>
<!-- ─────────────────────────────── ARCHITECTURE ────────────────────────────────── -->
<section id="architecture">
<h2>3. Proposed Architecture</h2>
<pre class="arch-diagram">
┌──────────────────────────────────────────────────────────────────────┐
│ nginx (reverse proxy) │
│ / → Next.js (port 3000) │
│ /admin → Strapi admin UI (port 1337/admin) │
│ /api → Strapi REST API (port 1337/api) │
│ /partage/:slug → Next.js (student submission route) │
└──────────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────────────────────────┐
│ Next.js 15 │ │ Strapi v5 (Node.js) │
│ (public site + │ │ │
│ student portal) │◄──►│ Content Types: Thesis, Author, │
│ │ │ Supervisor, Tag, Format, Language, │
│ - Home page │ │ ShareLink, AccessToken, SiteSettings │
│ - TFE detail │ │ │
│ - Search │ │ Plugins: │
│ - /partage form │ │ • share-link (custom routes + logic) │
│ - File proxy │ │ • file-access (token + cookie auth) │
└──────────────────┘ │ • peertube (upload integration) │
│ │
│ Media: local upload provider │
│ Database: SQLite (or PostgreSQL) │
└──────────────────────────────────────────┘
┌──────────────────────────┐
│ Storage (outside webroot) │
│ /var/www/xamxam/uploads/ │
│ (TFE PDFs, covers, │
│ annexes, video, audio) │
└──────────────────────────┘
</pre>
<h3>Key decisions</h3>
<ul>
<li><strong>SQLite is kept</strong> — Strapi supports it natively. Migration to PostgreSQL is a one-line config change if needed later.</li>
<li><strong>Files are never directly web-accessible</strong> — Next.js <code>/api/media/[...path]</code> proxies file requests, enforcing access-type checks (same as current <code>media.php</code>).</li>
<li><strong>Strapi admin panel replaces the custom PHP admin</strong> — thesis CRUD, jury management, share-link management, settings, and audit log all live there.</li>
<li><strong>Next.js App Router</strong> handles the public site and the <code>/partage/:slug</code> student submission portal.</li>
<li><strong>Single server deployment</strong> — both processes (<code>strapi start</code>, <code>next start</code>) run behind nginx, same as current setup.</li>
</ul>
</section>
<!-- ──────────────────────────────── FEATURE MAP ────────────────────────────────── -->
<section id="features">
<h2>4. Feature Map</h2>
<p>
Tags: <span class="tag native">native</span> = ships out-of-the-box with the CMS,
<span class="tag plugin">plugin</span> = available via an existing Strapi plugin or npm package,
<span class="tag custom">custom</span> = needs bespoke code,
<span class="tag drop">drop</span> = intentionally not reproduced (replaced or unnecessary).
</p>
<table class="feature-table">
<thead>
<tr>
<th>Feature (current XAMXAM)</th>
<th>CMS equivalent</th>
<th>Coverage</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<!-- Public site -->
<tr>
<td colspan="4" style="background:#f8f7f4;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#6b6b6b">Public site</td>
</tr>
<tr>
<td>Home page — latest year, random order, year filter</td>
<td>Next.js page fetching Strapi <code>/api/theses</code></td>
<td><span class="tag native">native</span></td>
<td>Strapi REST supports sort, filter, populate. Random order via <code>RANDOM()</code> is a custom sort plugin or a shuffle in Next.js.</td>
</tr>
<tr>
<td>Paginated browse (24/page)</td>
<td>Strapi pagination params (<code>pagination[page]</code>)</td>
<td><span class="tag native">native</span></td>
<td></td>
</tr>
<tr>
<td>Full-text search (title, author, keyword, year, orientation)</td>
<td>Strapi filters + Next.js search page</td>
<td><span class="tag native">native</span></td>
<td>Strapi's <code>$containsi</code> operator covers basic search. For true FTS, add the <code>strapi-plugin-fuzzy-search</code> package.</td>
</tr>
<tr>
<td>Rate limiting on search</td>
<td>nginx <code>limit_req_zone</code> or Strapi middleware</td>
<td><span class="tag plugin">plugin</span></td>
<td><code>koa-ratelimit</code> as a Strapi middleware.</td>
</tr>
<tr>
<td>TFE detail page with all metadata</td>
<td>Next.js dynamic route <code>/tfe/[id]</code></td>
<td><span class="tag native">native</span></td>
<td>Use <code>populate[]=authors&populate[]=jury&populate[]=files</code> etc.</td>
</tr>
<tr>
<td>OG / Twitter Card meta tags</td>
<td>Next.js <code>generateMetadata()</code></td>
<td><span class="tag native">native</span></td>
<td></td>
</tr>
<tr>
<td>Email obfuscation for author contact</td>
<td>Client-side JS decode or Next.js server component</td>
<td><span class="tag custom">custom</span></td>
<td>~10 lines of logic. No CMS ships this.</td>
</tr>
<tr>
<td>Maintenance mode</td>
<td>Next.js middleware redirect + flag env var</td>
<td><span class="tag custom">custom</span></td>
<td>Simple; <code>MAINTENANCE=1</code> → middleware returns 503 page.</td>
</tr>
<!-- File serving -->
<tr>
<td colspan="4" style="background:#f8f7f4;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#6b6b6b">File serving &amp; access control</td>
</tr>
<tr>
<td>Controlled file proxy (PDFs, images, video, audio)</td>
<td>Next.js API route <code>/api/media/[...path]</code></td>
<td><span class="tag custom">custom</span></td>
<td>Replicates current <code>media.php</code>. Streams file, validates MIME, enforces access type.</td>
</tr>
<tr>
<td>Access types: open / restricted / forbidden</td>
<td>Strapi <code>access_type</code> relation + media proxy</td>
<td><span class="tag custom">custom</span></td>
<td>Strapi stores the type; the proxy enforces it.</td>
</tr>
<tr>
<td>Cookie-based restricted access tokens</td>
<td>Custom <code>file-access</code> Strapi plugin</td>
<td><span class="tag custom">custom</span></td>
<td>Generates signed tokens, stores in DB, sets <code>HttpOnly</code> cookies. Same logic as current <code>FileAccessController.php</code>.</td>
</tr>
<tr>
<td>File labels, sort order per thesis</td>
<td>Strapi relation with pivot fields</td>
<td><span class="tag native">native</span></td>
<td>Strapi allows custom fields on many-to-many pivot tables (v5 "dynamic zones" or custom join table).</td>
</tr>
<!-- Admin panel -->
<tr>
<td colspan="4" style="background:#f8f7f4;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#6b6b6b">Admin panel</td>
</tr>
<tr>
<td>Thesis CRUD (create / edit / delete)</td>
<td>Strapi admin content manager</td>
<td><span class="tag native">native</span></td>
<td>All fields configured in content-type schema. No PHP to write.</td>
</tr>
<tr>
<td>Publish / unpublish thesis</td>
<td>Strapi draft &amp; publish system</td>
<td><span class="tag native">native</span></td>
<td>Toggle on the content entry. Matches current <code>is_published</code> flag.</td>
</tr>
<tr>
<td>Author management (name, email, contact visibility)</td>
<td>Strapi <code>Author</code> content type with relation</td>
<td><span class="tag native">native</span></td>
<td>Authors are a separate content type, reusable across theses.</td>
</tr>
<tr>
<td>Jury management (role, interne/externe/ULB)</td>
<td>Strapi <code>Supervisor</code> type + pivot role field</td>
<td><span class="tag native">native</span></td>
<td>Role dropdown (president / promoteur / lecteur) on the relation. ULB flag as boolean.</td>
</tr>
<tr>
<td>File upload queue with drag-reorder</td>
<td>Strapi Media Library + custom admin plugin UI</td>
<td><span class="tag custom">custom</span></td>
<td>Strapi's built-in media library handles upload. Drag-reorder with a custom "sort_order" component needs a small admin UI plugin (~12 days).</td>
</tr>
<tr>
<td>PeerTube video/audio upload</td>
<td>Custom <code>peertube</code> Strapi plugin</td>
<td><span class="tag custom">custom</span></td>
<td>Replicates <code>PeerTubeService.php</code>. OAuth2 password grant, credential encryption, token refresh on 401. ~35 days.</td>
</tr>
<tr>
<td>Share-link management (create, toggle, archive)</td>
<td>Custom <code>share-link</code> Strapi plugin</td>
<td><span class="tag custom">custom</span></td>
<td>Custom content type + admin panel list view. Slug generation, optional password hash, expiry, usage count. ~34 days.</td>
</tr>
<tr>
<td>CSV export</td>
<td>Custom Strapi controller or admin plugin action</td>
<td><span class="tag custom">custom</span></td>
<td>~1 day. Strapi lifecycle hooks make CSV generation straightforward.</td>
</tr>
<tr>
<td>Database / file archive export</td>
<td>Custom Strapi controller (zip SQLite + storage dir)</td>
<td><span class="tag custom">custom</span></td>
<td>~1 day. Identical logic to current <code>ExportController.php</code>.</td>
</tr>
<tr>
<td>Audit log</td>
<td>Strapi <code>strapi-plugin-audit-log</code> or custom</td>
<td><span class="tag plugin">plugin</span></td>
<td>Community plugin <code>strapi-audit-log</code> covers create/update/delete. Custom entries for share-link events.</td>
</tr>
<tr>
<td>Admin login (htpasswd + PHP session)</td>
<td>Strapi native JWT-based admin auth</td>
<td><span class="tag native">native</span></td>
<td>nginx <code>auth_basic</code> layer can remain as a second factor if desired.</td>
</tr>
<tr>
<td>Site settings (SMTP, PeerTube, help text)</td>
<td>Strapi Single-Type <code>SiteSettings</code></td>
<td><span class="tag native">native</span></td>
<td>Single-Type entries allow one-off config records. Encrypted fields via Strapi lifecycle hooks.</td>
</tr>
<tr>
<td>SMTP send with encrypted password</td>
<td>Custom Strapi service + <code>nodemailer</code></td>
<td><span class="tag plugin">plugin</span></td>
<td><code>@strapi/provider-email-nodemailer</code> for delivery. AES encryption of the stored password via a lifecycle hook.</td>
</tr>
<tr>
<td>Inline form help text (configurable per field)</td>
<td>Strapi Single-Type <code>FormHelp</code> + admin UI</td>
<td><span class="tag native">native</span></td>
<td>Store help blocks as a Single-Type with one entry per field. Renders in the Next.js form.</td>
</tr>
<tr>
<td>Import from CSV / legacy data</td>
<td>Custom Strapi import script (run once)</td>
<td><span class="tag custom">custom</span></td>
<td>One-time migration. ~35 days depending on data quality.</td>
</tr>
<!-- Student submission -->
<tr>
<td colspan="4" style="background:#f8f7f4;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#6b6b6b">Student submission portal (/partage)</td>
</tr>
<tr>
<td>Share-link access (slug + optional password)</td>
<td>Next.js <code>/partage/[slug]</code> + Strapi <code>/api/share-links/:slug/validate</code></td>
<td><span class="tag custom">custom</span></td>
<td>Custom Strapi route validates slug, checks password, expiry, usage count. Issues a JWT session for the student.</td>
</tr>
<tr>
<td>Student TFE submission form</td>
<td>Next.js form + Strapi <code>/api/share-links/:slug/submit</code></td>
<td><span class="tag custom">custom</span></td>
<td>Replicates the full <code>/partage/index.php</code> form. All validation server-side in Strapi. ~46 days.</td>
</tr>
<tr>
<td>Incremental file upload queue with reorder</td>
<td>Next.js client-side queue + Strapi upload endpoint</td>
<td><span class="tag custom">custom</span></td>
<td>Client-side JS queue (as planned in <code>TODO.md</code>). Submits FormData on final form submit. Same plan as the current in-progress refactor.</td>
</tr>
<tr>
<td>Email confirmation on submission</td>
<td>Strapi lifecycle hook → nodemailer</td>
<td><span class="tag plugin">plugin</span></td>
<td>On thesis creation event, fire email via the nodemailer provider.</td>
</tr>
<tr>
<td>Summary / recap page</td>
<td>Next.js <code>/partage/[slug]/recap</code></td>
<td><span class="tag custom">custom</span></td>
<td>Renders after successful submission. ~0.5 days.</td>
</tr>
<!-- Misc -->
<tr>
<td colspan="4" style="background:#f8f7f4;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#6b6b6b">Lookup data &amp; taxonomy</td>
</tr>
<tr>
<td>Orientations, AP programs, finality types, languages, formats, license types</td>
<td>Strapi content types (or Enumeration fields)</td>
<td><span class="tag native">native</span></td>
<td>All managed via Strapi admin. Relations from the Thesis type.</td>
</tr>
<tr>
<td>Tags / keywords (free-text, multi-value)</td>
<td>Strapi <code>Tag</code> content type with many-to-many</td>
<td><span class="tag native">native</span></td>
<td>Autocomplete in admin via Strapi's default relation field.</td>
</tr>
</tbody>
</table>
</section>
<!-- ─────────────────────────────── REQUIREMENTS ─────────────────────────────────── -->
<section id="requirements">
<h2>5. Requirements</h2>
<div class="two-col">
<div>
<h3>Functional requirements</h3>
<ol>
<li>Public catalogue: browse, paginate, filter by year / orientation / keyword</li>
<li>Full-text search across title, author, synopsis, keywords</li>
<li>TFE detail page: all metadata, files (gated by access type), jury composition</li>
<li>Three file access tiers: open, restricted (cookie token), forbidden</li>
<li>Admin: full thesis lifecycle (draft → published → archived)</li>
<li>Admin: manage all lookup tables (orientations, formats, languages, licences…)</li>
<li>Admin: share-link CRUD (create, toggle, set password, set expiry, archive)</li>
<li>Admin: audit log of all content changes</li>
<li>Admin: CSV export and full database/file archive download</li>
<li>Admin: configurable SMTP relay with encrypted credential storage</li>
<li>Admin: configurable PeerTube integration (instance URL, credentials, channel, privacy)</li>
<li>Admin: inline form help text, configurable per field</li>
<li>Student portal: access via share link, optional password gate</li>
<li>Student portal: multi-file upload queue with reorder and removal</li>
<li>Student portal: full thesis submission form with inline validation</li>
<li>Student portal: email confirmation on successful submission</li>
<li>Maintenance mode toggle (admin-accessible while public site is blocked)</li>
<li>OG / Twitter Card meta tags on all public pages</li>
<li>WCAG 2.1 AA accessibility on public pages and student form</li>
</ol>
</div>
<div>
<h3>Non-functional requirements</h3>
<ol>
<li><strong>Security:</strong> no uploaded file is ever directly web-accessible; all served via authenticated proxy</li>
<li><strong>Security:</strong> SMTP and PeerTube passwords encrypted at rest (AES-256)</li>
<li><strong>Security:</strong> CSRF protection on all forms</li>
<li><strong>Security:</strong> rate limiting on public search and share-link validation endpoints</li>
<li><strong>Performance:</strong> cover images batch-loaded; ISR (Next.js Incremental Static Regen) for public pages</li>
<li><strong>Deployment:</strong> single VPS, two processes (Strapi + Next.js) behind nginx</li>
<li><strong>Database:</strong> SQLite for parity with current setup; PostgreSQL upgrade path must be supported</li>
<li><strong>Backups:</strong> existing <code>just deploy-db</code> + rsync workflow preserved or equivalent provided</li>
<li><strong>Internationalisation:</strong> UI in French; content supports multilingual entries (fr, nl, en)</li>
<li><strong>Maintainability:</strong> no custom admin UI beyond what Strapi's plugin API supports; avoid forks of the CMS core</li>
</ol>
<h3>Dependencies &amp; integrations</h3>
<ul>
<li>PeerTube instance (existing, ERG-hosted)</li>
<li>SMTP relay (existing ERG mail server)</li>
<li>nginx (existing, config updated for new port routing)</li>
<li>Existing SQLite database (migrated via one-time import script)</li>
<li>Existing file storage directory (copied to new upload path)</li>
</ul>
</div>
</div>
</section>
<!-- ─────────────────────────────────── TIMELINE ─────────────────────────────────── -->
<section id="timeline">
<h2>6. Delivery Timeline</h2>
<p>Estimated at <strong>1 developer, full-time</strong>. Reduce scope or add a second dev to compress.</p>
<div class="timeline">
<div class="phase">
<div class="phase-header">
<span class="phase-num">Phase 1</span>
<h3>Foundation &amp; Data Model</h3>
<span class="phase-duration">3 weeks</span>
</div>
<ul>
<li>Initialise Strapi v5 project with SQLite</li>
<li>Define all content types: <code>Thesis</code>, <code>Author</code>, <code>Supervisor</code>, <code>ThesisFile</code>, <code>Tag</code>, <code>Orientation</code>, <code>ApProgram</code>, <code>FinalityType</code>, <code>Language</code>, <code>FormatType</code>, <code>LicenseType</code>, <code>AccessType</code>, <code>SiteSettings</code>, <code>FormHelp</code></li>
<li>Configure relations and pivot fields (jury role, author order, file sort_order, file label)</li>
<li>Enable Draft &amp; Publish on <code>Thesis</code></li>
<li>Write data migration script: read SQLite → POST to Strapi API</li>
<li>Migrate all files to Strapi upload directory</li>
<li>Validate migrated data in admin panel</li>
<li><strong>Deliverable:</strong> Strapi running locally with full existing dataset</li>
</ul>
</div>
<div class="phase">
<div class="phase-header">
<span class="phase-num">Phase 2</span>
<h3>Public Site (Next.js)</h3>
<span class="phase-duration">4 weeks</span>
</div>
<ul>
<li>Initialise Next.js 15 App Router project</li>
<li>Home page: latest year random grid + year filter + pagination</li>
<li>Search page: full-text across all fields, filter controls</li>
<li>TFE detail page: all metadata sections, jury composition, file list</li>
<li>File proxy route <code>/api/media/[...path]</code> with access-type enforcement</li>
<li>OG / Twitter meta tags via <code>generateMetadata()</code></li>
<li>CSS port from current design (reuse existing <code>public.css</code>, <code>tfe.css</code>)</li>
<li>Email obfuscation component</li>
<li>Maintenance mode middleware</li>
<li><strong>Deliverable:</strong> public site feature-complete, pixel-comparable to current</li>
</ul>
</div>
<div class="phase">
<div class="phase-header">
<span class="phase-num">Phase 3</span>
<h3>Restricted Access + File Access Tokens</h3>
<span class="phase-duration">1 week</span>
</div>
<ul>
<li>Strapi <code>file-access</code> plugin: custom route <code>POST /api/file-access/request</code></li>
<li>Token generation, storage in <code>file_access_tokens</code> table, cookie issuance</li>
<li>File proxy reads cookie, validates token against DB</li>
<li>Access request form in Next.js TFE detail page</li>
<li>Admin panel: view / revoke tokens per thesis</li>
<li><strong>Deliverable:</strong> restricted file access parity with current</li>
</ul>
</div>
<div class="phase">
<div class="phase-header">
<span class="phase-num">Phase 4</span>
<h3>Student Submission Portal</h3>
<span class="phase-duration">4 weeks</span>
</div>
<ul>
<li>Strapi <code>share-link</code> plugin: content type + slug generation + validate/submit routes</li>
<li>Next.js <code>/partage/[slug]</code>: slug gate, optional password form</li>
<li>Student submission form: all fields (title, subtitle, authors, jury, synopsis, keywords, formats, languages, licence, files)</li>
<li>Client-side file upload queue (TFE, annexes, cover, video, audio) with drag-reorder, MIME/size validation</li>
<li>Server-side validation replicating current <code>ThesisCreateController</code> logic</li>
<li>Email confirmation via nodemailer on submission</li>
<li>Recap / summary page</li>
<li>Admin panel: share-link list with create / toggle / archive / set-password actions</li>
<li><strong>Deliverable:</strong> student portal fully functional end-to-end</li>
</ul>
</div>
<div class="phase">
<div class="phase-header">
<span class="phase-num">Phase 5</span>
<h3>Admin Completions</h3>
<span class="phase-duration">3 weeks</span>
</div>
<ul>
<li>PeerTube plugin: settings form, OAuth2 token management, upload action on thesis edit</li>
<li>CSV export endpoint</li>
<li>Database + files archive download endpoint</li>
<li>Audit log plugin (or configure <code>strapi-audit-log</code>)</li>
<li>Admin file-reorder UI plugin (drag handles on thesis media list)</li>
<li>SMTP settings + test-send + encrypted storage via lifecycle hook</li>
<li>FormHelp single-type admin UI + rendering in student form</li>
<li><strong>Deliverable:</strong> all admin features present, team can self-manage</li>
</ul>
</div>
<div class="phase">
<div class="phase-header">
<span class="phase-num">Phase 6</span>
<h3>QA, Hardening &amp; Cutover</h3>
<span class="phase-duration">3 weeks</span>
</div>
<ul>
<li>Security review: headers, rate limiting, CSRF, upload validation, path traversal</li>
<li>WCAG 2.1 AA audit on public pages and student form</li>
<li>Performance: ISR on TFE pages, cover image optimisation (Next.js <code>Image</code>)</li>
<li>nginx config update for new port routing</li>
<li>Staging deploy: run both old and new in parallel; team acceptance testing</li>
<li>Production cutover: deploy new stack, migrate DB + files, update DNS if needed</li>
<li>Post-cutover monitoring (1 week watch period)</li>
<li><strong>Deliverable:</strong> production running on new stack</li>
</ul>
</div>
</div>
<div class="card" style="margin-top:1rem">
<strong>Total: 18 weeks (approx. 4.5 months)</strong> at 1 full-time developer.
Add 46 weeks buffer for stakeholder review cycles, scope additions, or part-time availability.
Realistic end-to-end: <strong>2224 weeks from kickoff to production</strong>.
</div>
</section>
<!-- ─────────────────────────────────── EFFORT ─────────────────────────────────── -->
<section id="effort">
<h2>7. Effort Breakdown</h2>
<table class="effort-table">
<thead>
<tr>
<th>Area</th>
<th>Est. days</th>
<th class="bar-cell">Relative effort</th>
<th>Main tasks</th>
</tr>
</thead>
<tbody>
<tr>
<td>Data model &amp; migration</td>
<td>10</td>
<td class="bar-cell"><div class="bar-wrap"><div class="bar" style="width:55%"></div></div></td>
<td>Schema definition, migration script, data validation</td>
</tr>
<tr>
<td>Public site (Next.js)</td>
<td>15</td>
<td class="bar-cell"><div class="bar-wrap"><div class="bar" style="width:83%"></div></div></td>
<td>All public pages, file proxy, metadata, CSS port</td>
</tr>
<tr>
<td>File access control plugin</td>
<td>5</td>
<td class="bar-cell"><div class="bar-wrap"><div class="bar" style="width:28%"></div></div></td>
<td>Token generation, cookie auth, admin revoke view</td>
</tr>
<tr>
<td>Student submission portal</td>
<td>18</td>
<td class="bar-cell"><div class="bar-wrap"><div class="bar" style="width:100%"></div></div></td>
<td>Share-link plugin, student form, file queue, email confirm</td>
</tr>
<tr>
<td>Admin completions</td>
<td>14</td>
<td class="bar-cell"><div class="bar-wrap"><div class="bar" style="width:78%"></div></div></td>
<td>PeerTube plugin, exports, audit log, SMTP, file reorder UI</td>
</tr>
<tr>
<td>QA, security &amp; cutover</td>
<td>13</td>
<td class="bar-cell"><div class="bar-wrap"><div class="bar" style="width:72%"></div></div></td>
<td>Security review, WCAG, performance, staging, production deploy</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td>75 days (~15 weeks)</td>
<td class="bar-cell"></td>
<td>+ 34 weeks buffer = 1819 weeks</td>
</tr>
</tfoot>
</table>
<div class="callout warn" style="margin-top:1.25rem">
<div class="callout-title">Cost note</div>
Strapi Community Edition is free / open-source. Strapi Cloud (hosted) starts at $29/month.
For self-hosted (current VPS setup), <strong>no licence fee applies</strong>.
Next.js is MIT. The only paid dependency is a Vercel deployment (optional — self-hosted Node.js is free).
Total additional infrastructure cost: <strong>$0/month on existing VPS</strong>.
</div>
</section>
<!-- ──────────────────────────────── INVOICE ─────────────────────────────────────── -->
<section id="invoice">
<h2>8. Invoice Example — Junior Solo Developer</h2>
<p>
The figures below illustrate what a realistic invoice from a junior freelance developer might look like
for this project. Assumptions: <strong>€350/day</strong> (junior solo rate, Western Europe),
75 billable days of work spread across 1819 weeks, with a 10% contingency buffer added as a separate
line item. VAT at 21% (Belgian standard rate, adjust for your jurisdiction).
</p>
<div class="invoice">
<div class="invoice-header">
<div class="from">
<strong>Dev Freelance — Jane Doe</strong>
Rue de l'Exemple 12<br>
1000 Bruxelles<br>
TVA: BE 0123.456.789<br>
jane@example.dev
</div>
<div class="inv-meta">
<strong>FACTURE / INVOICE</strong>
N° 2026-042<br>
Date: 10 mai 2026<br>
Échéance: 10 juin 2026<br>
Réf client: ERG / XAMXAM
</div>
</div>
<div class="invoice-to">
<div class="label">Facturé à / Billed to</div>
<p>
<strong>École de Recherche Graphique (ERG)</strong><br>
Rue du Page 87, 1050 Bruxelles<br>
TVA: BE 0400.000.000
</p>
</div>
<table class="invoice-lines">
<thead>
<tr>
<th style="width:45%">Description</th>
<th class="num" style="width:12%">Jours</th>
<th class="num" style="width:15%">Taux jour</th>
<th class="num" style="width:14%">Total HT</th>
</tr>
</thead>
<tbody>
<tr class="section-head"><td colspan="4">Phase 1 — Data model &amp; migration</td></tr>
<tr>
<td>Définition des content-types Strapi (Thesis, Author, Supervisor, Tag, etc.) + relations et champs pivot</td>
<td class="num">5</td>
<td class="num">350 €</td>
<td class="num">1 750 €</td>
</tr>
<tr>
<td>Script de migration SQLite → Strapi API + transfert des fichiers, validation des données</td>
<td class="num">5</td>
<td class="num">350 €</td>
<td class="num">1 750 €</td>
</tr>
<tr class="section-head"><td colspan="4">Phase 2 — Site public (Next.js)</td></tr>
<tr>
<td>Page d'accueil, filtres par année, pagination (24/page)</td>
<td class="num">3</td>
<td class="num">350 €</td>
<td class="num">1 050 €</td>
</tr>
<tr>
<td>Page de recherche (filtres titre / auteur / mot-clé / année / orientation)</td>
<td class="num">3</td>
<td class="num">350 €</td>
<td class="num">1 050 €</td>
</tr>
<tr>
<td>Page de détail TFE (métadonnées, jury, fichiers, OG tags)</td>
<td class="num">4</td>
<td class="num">350 €</td>
<td class="num">1 400 €</td>
</tr>
<tr>
<td>Proxy de fichiers <code>/api/media/[…path]</code> + contrôle d'accès + port CSS</td>
<td class="num">5</td>
<td class="num">350 €</td>
<td class="num">1 750 €</td>
</tr>
<tr class="section-head"><td colspan="4">Phase 3 — Accès restreint aux fichiers</td></tr>
<tr>
<td>Plugin Strapi <code>file-access</code> (tokens, cookies, révocation admin)</td>
<td class="num">5</td>
<td class="num">350 €</td>
<td class="num">1 750 €</td>
</tr>
<tr class="section-head"><td colspan="4">Phase 4 — Portail étudiant (/partage)</td></tr>
<tr>
<td>Plugin Strapi <code>share-link</code> (génération slug, validation, routes submit)</td>
<td class="num">5</td>
<td class="num">350 €</td>
<td class="num">1 750 €</td>
</tr>
<tr>
<td>Formulaire de dépôt étudiant (Next.js) — tous les champs, validation serveur</td>
<td class="num">6</td>
<td class="num">350 €</td>
<td class="num">2 100 €</td>
</tr>
<tr>
<td>File upload queue côté client (tri, suppression, validation MIME/taille)</td>
<td class="num">4</td>
<td class="num">350 €</td>
<td class="num">1 400 €</td>
</tr>
<tr>
<td>Email de confirmation (nodemailer) + page de récapitulatif</td>
<td class="num">3</td>
<td class="num">350 €</td>
<td class="num">1 050 €</td>
</tr>
<tr class="section-head"><td colspan="4">Phase 5 — Completions admin</td></tr>
<tr>
<td>Plugin PeerTube (OAuth2, upload vidéo/audio, chiffrement credentials)</td>
<td class="num">4</td>
<td class="num">350 €</td>
<td class="num">1 400 €</td>
</tr>
<tr>
<td>Export CSV + archive DB/fichiers</td>
<td class="num">2</td>
<td class="num">350 €</td>
<td class="num">700 €</td>
</tr>
<tr>
<td>Audit log, SMTP chiffré, FormHelp, UI tri fichiers</td>
<td class="num">4</td>
<td class="num">350 €</td>
<td class="num">1 400 €</td>
</tr>
<tr>
<td>Gestion share-links dans le panel admin (liste, toggle, archive, mot de passe)</td>
<td class="num">4</td>
<td class="num">350 €</td>
<td class="num">1 400 €</td>
</tr>
<tr class="section-head"><td colspan="4">Phase 6 — QA, sécurité &amp; mise en production</td></tr>
<tr>
<td>Audit sécurité (headers, rate-limiting, CSRF, upload, path traversal)</td>
<td class="num">3</td>
<td class="num">350 €</td>
<td class="num">1 050 €</td>
</tr>
<tr>
<td>Audit accessibilité WCAG 2.1 AA (site public + formulaire étudiant)</td>
<td class="num">2</td>
<td class="num">350 €</td>
<td class="num">700 €</td>
</tr>
<tr>
<td>Configuration nginx, déploiement staging, tests d'acceptation, mise en production</td>
<td class="num">4</td>
<td class="num">350 €</td>
<td class="num">1 400 €</td>
</tr>
<tr>
<td>Monitoring post-lancement (1 semaine), corrections mineures incluses</td>
<td class="num">4</td>
<td class="num">350 €</td>
<td class="num">1 400 €</td>
</tr>
<tr class="section-head"><td colspan="4">Réserve</td></tr>
<tr>
<td>Contingence 10 % — imprévus, retours client, ajustements de scope</td>
<td class="num">7.5</td>
<td class="num">350 €</td>
<td class="num">2 625 €</td>
</tr>
</tbody>
</table>
<div class="invoice-totals">
<table>
<tr>
<td>Sous-total HT (75 jours × 350 €)</td>
<td>26 250 €</td>
</tr>
<tr>
<td>Contingence 10 % (7.5 jours × 350 €)</td>
<td>2 625 €</td>
</tr>
<tr>
<td>Total HT</td>
<td>28 875 €</td>
</tr>
<tr>
<td>TVA 21 %</td>
<td>6 063.75 €</td>
</tr>
<tr class="grand">
<td>TOTAL TTC</td>
<td>34 938.75 €</td>
</tr>
</table>
</div>
<div class="invoice-footer">
<strong>Conditions de paiement :</strong> 30 jours net. Paiement par virement bancaire.<br>
<strong>Jalons de facturation suggérés :</strong> 30 % à la signature — 30 % à la livraison de la phase 2 (site public) — 30 % à la livraison de la phase 4 (portail étudiant) — 10 % à la mise en production.<br>
<strong>Note :</strong> Ce devis est indicatif. Le tarif journalier d'un développeur junior en Belgique se situe typiquement entre <strong>300 € et 450 €/jour</strong>. Un profil senior (550850 €/jour) réduirait la durée d'environ 2030 %. Les licences logicielles (Strapi Community, Next.js, Node.js) sont gratuites. Aucun coût d'hébergement supplémentaire si le VPS existant est conservé.
</div>
</div>
</section>
<!-- ──────────────────────────────── TRADE-OFFS ──────────────────────────────────── -->
<section id="tradeoffs">
<h2>9. Trade-offs vs. Current Custom Build</h2>
<div class="two-col">
<div class="card">
<h3 style="color:var(--ok)">What you gain</h3>
<ul>
<li><strong>No bespoke admin panel maintenance</strong> — Strapi generates the CRUD UI from schema; adding a field to a thesis takes 2 minutes in the GUI, not 30 lines of PHP</li>
<li><strong>Schema changes are safe</strong> — Strapi manages migrations automatically; no hand-written <code>.sql</code> files</li>
<li><strong>REST + GraphQL API out-of-the-box</strong> — a future mobile app, integration, or external catalogue can consume the API immediately</li>
<li><strong>Media Library UI</strong> — upload, rename, and organise files through a proper UI with thumbnails</li>
<li><strong>Role-based access control built-in</strong> — adding a new admin user takes seconds; no <code>htpasswd</code> management</li>
<li><strong>Community plugins</strong> for audit log, search, email — maintained by others</li>
<li><strong>Upgrade path to PostgreSQL</strong> — one config line; no schema rewrite</li>
<li><strong>TypeScript end-to-end</strong> — type safety from API response to rendered component</li>
</ul>
</div>
<div class="card">
<h3 style="color:var(--danger)">What you lose / accept</h3>
<ul>
<li><strong>Operational simplicity</strong> — two processes (Strapi + Next.js) instead of one PHP server; slightly more to monitor and deploy</li>
<li><strong>PHP familiarity</strong> — custom plugins are JavaScript/TypeScript; the team needs to learn (or hire for) Node.js</li>
<li><strong>Smaller codebase for simple tasks</strong> — a 20-line PHP controller becomes a Strapi plugin with boilerplate. The abstraction is a net win at scale, but adds friction for tiny changes</li>
<li><strong>Strapi CMS upgrades</strong> — major version upgrades (v4→v5 was breaking) require effort; the team is now on a CMS release cadence</li>
<li><strong>Node.js on the server</strong> — higher baseline memory (~150MB for Strapi) vs. PHP's per-request model (~20MB)</li>
<li><strong>HTMX hypermedia patterns lost</strong> — replaced by conventional React client-side interactivity; not a functional loss, but a philosophical one for the current codebase's author</li>
</ul>
</div>
</div>
</section>
<!-- ──────────────────────────────── NOT INCLUDED ────────────────────────────────── -->
<section id="not-included">
<h2>10. What Is Not Included (Out of Scope)</h2>
<div class="callout warn">
<div class="callout-title">Intentionally deferred or dropped</div>
<ul style="margin-bottom:0">
<li><strong>LDAP integration</strong> — documented in <code>LDAP_SPEC.md</code> but not currently implemented; deferred to a post-launch phase. Strapi's Users &amp; Permissions plugin supports LDAP via a third-party provider when ready.</li>
<li><strong>Redesign</strong> — this plan reproduces the current design and UX faithfully. No visual redesign is included.</li>
<li><strong>Multi-tenancy / multi-school</strong> — not a current requirement. Strapi supports this but it would be a separate project.</li>
<li><strong>Full-text search indexing (Elasticsearch / Meilisearch)</strong> — Strapi's built-in filter-based search is sufficient at ERG's dataset size (~hundreds of theses). Meilisearch integration can be added as a plugin later if needed.</li>
<li><strong>Video player / streaming</strong> — current app embeds PeerTube's existing player via URL; that behaviour is preserved. No custom video player is built.</li>
<li><strong>Strapi Cloud deployment</strong> — plan assumes self-hosted on the existing ERG VPS. Cloud hosting is a drop-in option at any point.</li>
</ul>
</div>
<h3>Migration data quality risks</h3>
<div class="callout danger">
<div class="callout-title">Data migration risks to verify before Phase 1</div>
<ul style="margin-bottom:0">
<li>SQLite views (<code>v_theses_full</code>, <code>v_theses_public</code>) aggregate denormalised strings (comma-separated authors etc.) — the migration script must split these back into proper relations</li>
<li>Banner/cover path consolidation (<code>027_drop_banner_path.sql</code> pending) — resolve pending migrations before migrating data</li>
<li>Legacy artefacts in <code>oui/non</code> fields (<code>025_fix_oui_non_artefacts.sql</code> pending) — same</li>
<li>Encrypted SMTP password — must be decrypted during migration (requires the current <code>Crypto.php</code> key) and re-encrypted by Strapi lifecycle hook</li>
</ul>
</div>
</section>
</main>
</body>
</html>