fix: display db timestamps in Brussels time

(heure de dépôt was showing UTC)

- feat: add date_depot column (real TFE deposit date)
with CSV round-trip + Brussels→UTC sanitization
- fix: keep PHP default tz at UTC to preserve token/share-link
 expiry consistency; convert to Brussels only in db_datetime()
This commit is contained in:
Pontoporeia
2026-09-18 16:26:36 +02:00
parent 2232981753
commit 30a16f9e9e
13 changed files with 280 additions and 9 deletions
+1
View File
@@ -7,6 +7,7 @@ app/storage/test.db
*.db
*.db-wal
*.db-shm
*.db.gz
app/.env
### Logs ###
+1
View File
@@ -3,3 +3,4 @@ nginx
src/cache/rate_limit
*.db-wal
*.db-shm
*.db.gz
+36
View File
@@ -10,6 +10,42 @@ $autoloadPath = file_exists(__DIR__ . '/vendor/autoload.php')
: __DIR__ . '/../vendor/autoload.php';
require_once $autoloadPath;
// The site serves the ERG (Brussels), but the server + SQLite timestamps run in
// UTC (CURRENT_TIMESTAMP). We intentionally do NOT change the PHP default
// timezone: code that writes timestamps via date()/time() (e.g. one-time
// tokens, share-link expiries) must stay wall-clock consistent with SQLite's
// CURRENT_TIMESTAMP (UTC), otherwise expiries drift by the UTC↔Brussels offset.
// Conversion to Brussels is handled explicitly at the display layer via
// db_datetime() below.
/**
* Format a database-stored datetime for display in Brussels local time.
*
* SQLite timestamps (submitted_at, created_at, published_at, …) are written
* with CURRENT_TIMESTAMP (= UTC). This parses the value as UTC and converts it
* to Europe/Brussels (UTC+1 winter / UTC+2 summer) — independent of the PHP
* default timezone, so it never affects DB write/read consistency elsewhere.
*/
function db_datetime(string $raw, string $format = 'd/m/Y à H:i'): string
{
if ($raw === '') {
return '';
}
$dt = DateTime::createFromFormat('Y-m-d H:i:s', $raw, new DateTimeZone('UTC'));
if ($dt === false) {
// Non-ISO fallback: parse the naive string as UTC.
$ts = strtotime($raw . ' UTC');
if ($ts === false) {
return $raw;
}
$dt = (new DateTime('@' . $ts))->setTimezone(new DateTimeZone('UTC'));
}
$dt->setTimezone(new DateTimeZone('Europe/Brussels'));
return $dt->format($format);
}
// Define application root
define('APP_ROOT', __DIR__);
@@ -0,0 +1,129 @@
<?php
/**
* Migration 044 — add `date_depot` column to `theses`.
*
* `submitted_at` was historically used as "when the record entered the system"
* (set to CURRENT_TIMESTAMP on create/CSV import). That conflates two concepts:
* the *system* submission time and the *student's* real TFE deposit date. When
* theses were bulk-imported via CSV, every imported row got stamped with the
* same CURRENT_TIMESTAMP, losing the real deposit date.
*
* This migration:
* 1. Adds a nullable `date_depot DATETIME` column to hold the real deposit date.
* 2. Backfills `date_depot` from `submitted_at` for theses that have not been
* bulk-imported (i.e. where submitted_at differs from the import time) —
* this is conservative: it copies the existing value so nothing is lost,
* while real per-thesis values must be re-supplied via a CSV import
* (or the admin edit form) since the bulk import already flattened them.
* 3. Recreates v_theses_full to expose the new column.
*
* Idempotent: safe to re-run (guards on existing column).
*/
defined('APP_ROOT') || define('APP_ROOT', dirname(__DIR__, 2));
defined('STORAGE_ROOT') || define('STORAGE_ROOT', APP_ROOT . '/storage');
$dbPath = $argv[1] ?? (APP_ROOT . '/storage/xamxam.db');
if (!file_exists($dbPath)) {
echo "ERROR: database not found at $dbPath\n";
exit(1);
}
$pdo = new PDO('sqlite:' . $dbPath);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// 1. Add the column if missing.
$cols = array_column($pdo->query("PRAGMA table_info(theses)")->fetchAll(), 'name');
if (!in_array('date_depot', $cols, true)) {
$pdo->exec("ALTER TABLE theses ADD COLUMN date_depot DATETIME");
echo "Added column theses.date_depot\n";
} else {
echo "Column theses.date_depot already present\n";
}
// 2. Backfill: for any thesis with a submitted_at but no date_depot, copy it.
// (Conservative — preserves the value so display still works; the real
// deposit date must be supplied via a later CSV import for bulk-imported rows.)
$updated = $pdo->exec(
"UPDATE theses SET date_depot = submitted_at WHERE date_depot IS NULL AND submitted_at IS NOT NULL"
);
echo "Backfilled date_depot from submitted_at for {$updated} row(s)\n";
// 3. Recreate v_theses_full so it exposes date_depot.
$pdo->exec("DROP VIEW IF EXISTS v_theses_full");
$pdo->exec(
"CREATE VIEW v_theses_full AS
SELECT
t.id,
t.identifier,
t.title,
t.subtitle,
t.year,
t.is_doctoral,
t.objet,
o.name as orientation,
ap.name as ap_program,
ft.name as finality_type,
t.synopsis,
t.context_note,
t.duration_pages,
t.duration_minutes,
t.has_annexes,
at.name as access_type,
lt.name as license_type,
t.license_id,
t.license_custom,
t.access_type_id,
t.jury_points,
t.submitted_at,
t.date_depot,
t.defense_date,
t.published_at,
t.is_published,
t.baiu_link,
t.exemplaire_baiu,
t.exemplaire_erg,
t.cc2r,
t.remarks,
t.jury_note_added,
t.contact_visible,
GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors,
GROUP_CONCAT(DISTINCT s.name) as supervisors,
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president,
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 0 THEN s.name END) as jury_promoteurs,
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 1 THEN s.name END) as jury_promoteurs_ulb,
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 0 THEN s.name END) as jury_lecteurs_internes,
GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 1 THEN s.name END) as jury_lecteurs_externes,
GROUP_CONCAT(DISTINCT l.name) as languages,
GROUP_CONCAT(DISTINCT fmt.name) as formats,
GROUP_CONCAT(DISTINCT tg.name) as keywords,
(SELECT a2.email FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as contact_interne,
(SELECT a2.show_contact FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as contact_public
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
LEFT JOIN finality_types ft ON t.finality_id = ft.id
LEFT JOIN access_types at ON t.access_type_id = at.id
LEFT JOIN license_types lt ON t.license_id = lt.id
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
LEFT JOIN authors a ON ta.author_id = a.id
LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id
LEFT JOIN supervisors s ON ts.supervisor_id = s.id
LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id
LEFT JOIN languages l ON tl.language_id = l.id
LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id
LEFT JOIN format_types fmt ON tf.format_id = fmt.id
LEFT JOIN thesis_tags tt ON t.id = tt.thesis_id
LEFT JOIN tags tg ON tt.tag_id = tg.id
GROUP BY t.id;"
);
echo "Recreated v_theses_full with date_depot\n";
// v_theses_public is SELECT * FROM v_theses_full, so it inherits the new column
// automatically; re-assert it in case the old view is still cached.
$pdo->exec("DROP VIEW IF EXISTS v_theses_public");
$pdo->exec("CREATE VIEW IF NOT EXISTS v_theses_public AS SELECT * FROM v_theses_full WHERE is_published = 1;");
echo "Recreated v_theses_public\n";
echo "Migration 044 complete.\n";
+50 -2
View File
@@ -143,6 +143,51 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
return ($raw === 'oui' || $raw === '1' || $raw === 'yes' || $raw === 'true') ? 1 : 0;
};
// Helper: parse a bulk-import date cell into a UTC 'Y-m-d H:i:s' string.
// The cell is authored in Brussels local time (e.g. "12/05/2026",
// "12/05/2026 14:30", ISO "2026-05-12"). We interpret it in the
// Europe/Brussels timezone and convert to UTC for storage, matching
// the rest of the DB (SQLite CURRENT_TIMESTAMP) and the db_datetime()
// display helper which converts UTC → Brussels on read.
$parseBulkDate = function(string $raw): ?string {
$raw = trim($raw);
if ($raw === '') return null;
// Try a set of formats (d/m/Y first — the CSV convention).
// Date-only formats (no time component) are anchored to midnight
// local time; PHP otherwise fills a missing time with *now*.
$formats = [
'd/m/Y H:i:s', 'd/m/Y H:i', 'd/m/Y',
'Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d',
'd.m.Y H:i', 'd.m.Y',
];
foreach ($formats as $fmt) {
$dt = DateTime::createFromFormat($fmt, $raw, new DateTimeZone('Europe/Brussels'));
if ($dt === false) {
continue;
}
// Reject partial parses (e.g. "12/05/20xx" residual garbage).
$errors = DateTime::getLastErrors();
if ($errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) {
continue;
}
// Anchor date-only values to midnight local time.
if (!str_contains($fmt, 'H') && !str_contains($fmt, ':')) {
$dt->setTime(0, 0, 0);
}
$dt->setTimezone(new DateTimeZone('UTC'));
return $dt->format('Y-m-d H:i:s');
}
// ISO fallback via strtotime (assumes Brussels for naive strings).
$ts = strtotime($raw . ' Europe/Brussels');
if ($ts !== false) {
return gmdate('Y-m-d H:i:s', $ts);
}
return null;
};
// Code → canonical name (legacy short-code CSV format)
$orientationCodeMap = [
'SC'=>'Sculpture','VI'=>'Vidéographie','CA'=>"Cinéma d'animation",
@@ -305,6 +350,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
$contactVisible = $cell($row, 'contact-visible'); // second "contact", key won't match header → positional
$objetRaw = $cell($row, 'objet');
$objet = in_array($objetRaw, ['tfe', 'thèse', 'frart'], true) ? $objetRaw : 'tfe';
$dateDepotRaw = $cell($row, 'dépôt');
$dateDepot = parseBulkDate($dateDepotRaw); // Brussels-local → UTC (Y-m-d H:i:s) or null
if ($title === '' || $year === 0) {
$missing = [];
@@ -366,8 +413,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
cc2r, exemplaire_baiu, exemplaire_erg,
objet, contact_visible,
duration_pages, duration_minutes, has_annexes,
is_published, status, submitted_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,'active',CURRENT_TIMESTAMP)
is_published, status, submitted_at, date_depot
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,'active',CURRENT_TIMESTAMP,?)
");
$s->execute([
!empty($identifier) ? $identifier : null, $title,
@@ -389,6 +436,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
$durationPages,
$durationMinutes,
$hasAnnexes,
$dateDepot,
]);
$thesisId = $importPdo->lastInsertId();
+3
View File
@@ -290,6 +290,7 @@ class ExportController
['Licence personnalisée', 'license_custom', 'licence-perso'],
['Contact visible', 'contact_visible', 'contact-visible'],
['Objet', 'objet', 'objet'],
['Date de dépôt', 'date_depot', 'dépôt'],
];
/**
@@ -336,6 +337,7 @@ class ExportController
'Licence personnalisée',
'Contact visible',
'Objet',
'Date de dépôt',
];
/**
@@ -461,6 +463,7 @@ class ExportController
$t['license_custom'] ?? '',
$t['contact_visible'] ?? '',
$t['objet'] ?? '',
!empty($t['date_depot']) ? db_datetime((string)$t['date_depot'], 'd/m/Y H:i') : '',
];
}
+2 -1
View File
@@ -2722,7 +2722,8 @@ class Database
t.duration_pages,
t.duration_minutes,
t.has_annexes,
t.objet
t.objet,
t.date_depot
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
Binary file not shown.
+2
View File
@@ -99,6 +99,7 @@ CREATE TABLE IF NOT EXISTS theses (
jury_points DECIMAL(4,2),
jury_note_added BOOLEAN DEFAULT 0,
submitted_at DATETIME,
date_depot DATETIME, -- real TFE deposit date (bulk-import source, Brussels-local parse → UTC storage)
defense_date DATETIME,
published_at DATETIME,
is_published BOOLEAN DEFAULT 0,
@@ -447,6 +448,7 @@ SELECT
t.access_type_id,
t.jury_points,
t.submitted_at,
t.date_depot,
t.defense_date,
t.published_at,
t.is_published,
+8 -5
View File
@@ -178,18 +178,21 @@
<fieldset>
<legend>Dates et publication</legend>
<dl class="recap-dl">
<?php if ($thesis['date_depot']): ?>
<dt>Date de dépôt</dt><dd><?= htmlspecialchars(db_datetime($thesis['date_depot'], 'd/m/Y à H:i')) ?></dd>
<?php endif; ?>
<?php if ($thesis['submitted_at']): ?>
<dt>Soumis le</dt><dd><?= date('d/m/Y à H:i', strtotime($thesis['submitted_at'])) ?></dd>
<dt>Soumis le</dt><dd><?= htmlspecialchars(db_datetime($thesis['submitted_at'])) ?></dd>
<?php endif; ?>
<?php if ($thesis['defense_date']): ?>
<dt>Date de défense</dt><dd><?= date('d/m/Y', strtotime($thesis['defense_date'])) ?></dd>
<dt>Date de défense</dt><dd><?= htmlspecialchars(db_datetime($thesis['defense_date'], 'd/m/Y')) ?></dd>
<?php endif; ?>
<?php if ($thesis['published_at']): ?>
<dt>Publié le</dt><dd><?= date('d/m/Y à H:i', strtotime($thesis['published_at'])) ?></dd>
<dt>Publié le</dt><dd><?= htmlspecialchars(db_datetime($thesis['published_at'])) ?></dd>
<?php endif; ?>
<dt>Publié</dt><dd><?= !empty($thesis['is_published']) ? 'Oui' : 'Non' ?></dd>
<?php if ($thesis['jury_note_added']): ?>
<dt>Note du jury ajoutée</dt><dd><?= date('d/m/Y', strtotime($thesis['jury_note_added'])) ?></dd>
<dt>Note du jury ajoutée</dt><dd><?= htmlspecialchars(db_datetime($thesis['jury_note_added'], 'd/m/Y')) ?></dd>
<?php endif; ?>
</dl>
</fieldset>
@@ -273,7 +276,7 @@
</td>
<td class="recap-files-type"><?= htmlspecialchars($fileType) ?></td>
<td class="recap-files-size"><?= !empty($f['file_size']) && $f['file_size'] > 0 ? formatFileSize($f['file_size']) : '–' ?></td>
<td class="recap-files-date"><?= !empty($f['uploaded_at']) ? date('d/m/Y H:i', strtotime($f['uploaded_at'])) : '–' ?></td>
<td class="recap-files-date"><?= !empty($f['uploaded_at']) ? htmlspecialchars(db_datetime($f['uploaded_at'], 'd/m/Y H:i')) : '–' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
+1 -1
View File
@@ -376,7 +376,7 @@ deploy-backup-cron:
@echo "📋 Installing backup cron jobs…"
rsync -v deploy/xamxam-backup.cron xamxam:/tmp/xamxam-backup.cron
ssh -t xamxam "sudo install -o root -g root -m 644 /tmp/xamxam-backup.cron /etc/cron.d/xamxam-backup && rm -f /tmp/xamxam-backup.cron"
ssh -t xamxam "sudo mkdir -p /var/backups/xamxam && sudo chown www-data:www-data /var/backups/xamxam && sudo chmod 755 /var/backups/xamxam"
ssh -t xamxam "sudo mkdir -p /var/backups/xamxam && sudo chown www-data:xamxam /var/backups/xamxam && sudo chmod 775 /var/backups/xamxam"
ssh -t xamxam "sudo touch /var/log/xamxam-backup-\$(date +%Y-%m-%d).log && sudo chown www-data:www-data /var/log/xamxam-backup-\$(date +%Y-%m-%d).log && sudo chmod 644 /var/log/xamxam-backup-\$(date +%Y-%m-%d).log"
@echo "✅ Cron jobs installed."
@echo " Cron file: /etc/cron.d/xamxam-backup"
+8
View File
@@ -97,6 +97,14 @@ chown www-data:xamxam /var/log/xamxam
chmod 2775 /var/log/xamxam
ok "Log dir: /var/log/xamxam owned by www-data:xamxam (2775)"
# Backups dir must be writable by both www-data (cron) and the deploy user
# (xamxam group) so scripts/migrate.sh can write a pre-deploy snapshot before
# running migrations.
mkdir -p /var/backups/xamxam
chown www-data:xamxam /var/backups/xamxam
chmod 2775 /var/backups/xamxam
ok "Backup dir: /var/backups/xamxam owned by www-data:xamxam (2775)"
# ── Step 2: Nginx config ──────────────────────────────────────────────────────
printf "\n📋 Step 2: Deploying nginx configuration...\n"
echo "--------------------------------------------"
+39
View File
@@ -3,6 +3,10 @@
# Safe to run on existing databases — schema uses IF NOT EXISTS / INSERT OR IGNORE.
# Usage:
# scripts/migrate.sh # xamxam.db (default)
#
# Before any schema/migration is applied, a WAL-safe snapshot of the database is
# written (via sqlite3 .backup) so a deploy is always recoverable if a migration
# goes wrong. This augments the hourly cron backups with a pre-deploy checkpoint.
set -euo pipefail
@@ -13,15 +17,50 @@ if [ -d "$REPO_ROOT/app/storage" ]; then
SCHEMA="$REPO_ROOT/app/storage/schema.sql"
PROD_DB="$REPO_ROOT/app/storage/xamxam.db"
MIGRATIONS="$REPO_ROOT/app/migrations/run.php"
# Local dev DB is disposable — no pre-migration backup needed here.
BACKUP_DIR=""
elif [ -f "$REPO_ROOT/storage/schema.sql" ]; then
SCHEMA="$REPO_ROOT/storage/schema.sql"
PROD_DB="$REPO_ROOT/storage/xamxam.db"
MIGRATIONS="$REPO_ROOT/migrations/run.php"
BACKUP_DIR="/var/backups/xamxam"
else
echo "ERROR: cannot find storage/schema.sql" >&2
exit 1
fi
# ── Pre-migration backup (production remote only) ───────────────────────────
backup_before_migrate() {
[ -n "$BACKUP_DIR" ] || return 0
[ -f "$PROD_DB" ] || return 0
command -v sqlite3 >/dev/null 2>&1 || { echo " [backup] sqlite3 not found — skipping"; return 0; }
command -v gzip >/dev/null 2>&1 || { echo " [backup] gzip not found — skipping"; return 0; }
if ! mkdir -p "$BACKUP_DIR" 2>/dev/null; then
echo " [backup] WARNING: cannot write to $BACKUP_DIR — skipping pre-deploy backup" >&2
return 0
fi
local snapshot
snapshot="/tmp/xamxam-predeploy-$$.db"
if sqlite3 "$PROD_DB" ".backup '$snapshot'" 2>/dev/null; then
local out
out="$BACKUP_DIR/db-$(date +%Y-%m-%dT%H-%M-%S).db.gz"
gzip -c "$snapshot" > "$out"
rm -f "$snapshot"
echo " [backup] pre-deploy snapshot: $out"
else
rm -f "$snapshot"
echo " [backup] WARNING: sqlite3 .backup failed — continuing without a pre-deploy snapshot" >&2
fi
}
if [ -n "$BACKUP_DIR" ]; then
echo "──────────────────────────────────────────────"
echo " [backup] taking pre-migration snapshot…"
backup_before_migrate
fi
echo "──────────────────────────────────────────────"
echo " [schema] applying schema…"
if command -v syntaqlite &>/dev/null; then