mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
feat: implement SQLite backup & data integrity plan (Phases 2-4)
This commit is contained in:
80
app/src/Audit.php
Normal file
80
app/src/Audit.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Data-level audit logger.
|
||||
*
|
||||
* Writes a row to the `audit_log` table for every INSERT, UPDATE, or DELETE
|
||||
* on core data tables. Unlike AdminLogger (which tracks admin *actions*),
|
||||
* this captures the actual data change — before and after snapshots.
|
||||
*
|
||||
* Usage:
|
||||
* Audit::log($db, $actor, 'DELETE', 'tags', $tagId, $oldRow);
|
||||
* Audit::log($db, $actor, 'UPDATE', 'theses', $thesisId, $oldRow, $newRow);
|
||||
* Audit::log($db, $actor, 'INSERT', 'tags', $newId, null, $newRow);
|
||||
*/
|
||||
class Audit
|
||||
{
|
||||
/**
|
||||
* Log a data mutation.
|
||||
*
|
||||
* @param Database $db Database instance.
|
||||
* @param string $actor Who triggered this (IP, username, 'system', etc.)
|
||||
* @param string $action 'INSERT', 'UPDATE', or 'DELETE'.
|
||||
* @param string $tableName The table being mutated.
|
||||
* @param int|null $recordId The primary key of the affected row.
|
||||
* @param array|null $oldData Row data before the mutation (null for INSERT).
|
||||
* @param array|null $newData Row data after the mutation (null for DELETE).
|
||||
*/
|
||||
public static function log(
|
||||
Database $db,
|
||||
string $actor,
|
||||
string $action,
|
||||
string $tableName,
|
||||
?int $recordId,
|
||||
?array $oldData = null,
|
||||
?array $newData = null
|
||||
): void {
|
||||
try {
|
||||
$stmt = $db->getConnection()->prepare(
|
||||
'INSERT INTO audit_log (actor, action, table_name, record_id, old_data, new_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$actor,
|
||||
$action,
|
||||
$tableName,
|
||||
$recordId,
|
||||
$oldData !== null ? self::safeJsonEncode($oldData) : null,
|
||||
$newData !== null ? self::safeJsonEncode($newData) : null,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Audit logging is best-effort — never crash the app over it.
|
||||
error_log('[Audit] write failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the actor string from the current request context.
|
||||
*/
|
||||
public static function actor(): string
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'cli';
|
||||
$user = $_SESSION['admin_user'] ?? null;
|
||||
return $user ? "$user@$ip" : $ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-encode data, redacting sensitive/password fields.
|
||||
*/
|
||||
private static function safeJsonEncode(array $data): string
|
||||
{
|
||||
$safe = $data;
|
||||
// Redact password-like fields
|
||||
foreach (['password', 'pass', 'secret', 'token', 'credential'] as $key) {
|
||||
if (array_key_exists($key, $safe)) {
|
||||
$safe[$key] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
return json_encode($safe, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,17 @@ class Database
|
||||
return $root . '/storage/xamxam.db';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single row by ID from a table. Returns null if not found.
|
||||
*/
|
||||
private function fetchRow(string $table, int $id): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM $table WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
return $row !== false ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance (for front-backend)
|
||||
* @return Database
|
||||
@@ -154,7 +165,7 @@ class Database
|
||||
public function getLatestYearTheses(int $limit = 24): array
|
||||
{
|
||||
$sql = 'SELECT * FROM v_theses_public
|
||||
WHERE year = (SELECT MAX(year) FROM theses WHERE is_published = 1)
|
||||
WHERE year = (SELECT MAX(year) FROM theses WHERE is_published = 1 AND deleted_at IS NULL)
|
||||
ORDER BY RANDOM()
|
||||
LIMIT :limit';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
@@ -168,7 +179,7 @@ class Database
|
||||
*/
|
||||
public function getLatestPublishedYear(): ?int
|
||||
{
|
||||
$stmt = $this->pdo->query('SELECT MAX(year) FROM theses WHERE is_published = 1');
|
||||
$stmt = $this->pdo->query('SELECT MAX(year) FROM theses WHERE is_published = 1 AND deleted_at IS NULL');
|
||||
$val = $stmt->fetchColumn();
|
||||
return $val ? (int)$val : null;
|
||||
}
|
||||
@@ -178,7 +189,7 @@ class Database
|
||||
*/
|
||||
public function countPublishedTheses()
|
||||
{
|
||||
$sql = 'SELECT COUNT(*) as count FROM theses WHERE is_published = 1';
|
||||
$sql = 'SELECT COUNT(*) as count FROM theses WHERE is_published = 1 AND deleted_at IS NULL';
|
||||
$stmt = $this->pdo->query($sql);
|
||||
$result = $stmt->fetch();
|
||||
return $result['count'];
|
||||
@@ -479,7 +490,7 @@ class Database
|
||||
FROM theses t
|
||||
JOIN thesis_authors ta ON ta.thesis_id = t.id
|
||||
JOIN authors a ON a.id = ta.author_id
|
||||
WHERE t.is_published = 1
|
||||
WHERE t.is_published = 1 AND t.deleted_at IS NULL
|
||||
GROUP BY t.id
|
||||
ORDER BY MIN(a.name) ASC'
|
||||
);
|
||||
@@ -541,7 +552,7 @@ class Database
|
||||
|
||||
public function getAvailableYears()
|
||||
{
|
||||
$sql = 'SELECT DISTINCT year FROM theses WHERE is_published = 1 ORDER BY year DESC';
|
||||
$sql = 'SELECT DISTINCT year FROM theses WHERE is_published = 1 AND deleted_at IS NULL ORDER BY year DESC';
|
||||
$stmt = $this->pdo->query($sql);
|
||||
return $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
@@ -581,7 +592,7 @@ class Database
|
||||
$sql = 'SELECT DISTINCT tg.id, tg.name FROM tags tg
|
||||
JOIN thesis_tags tt ON tg.id = tt.tag_id
|
||||
JOIN theses th ON tt.thesis_id = th.id
|
||||
WHERE th.is_published = 1
|
||||
WHERE th.is_published = 1 AND th.deleted_at IS NULL AND tg.deleted_at IS NULL
|
||||
ORDER BY tg.name';
|
||||
$stmt = $this->pdo->query($sql);
|
||||
return $stmt->fetchAll();
|
||||
@@ -617,7 +628,7 @@ class Database
|
||||
|
||||
// Build WHERE + bindings excluding one dimension (for that column's own relevance)
|
||||
$buildWhere = function (string $exclude) use ($filters): array {
|
||||
$conditions = ['t.is_published = 1'];
|
||||
$conditions = ['t.is_published = 1', 't.deleted_at IS NULL'];
|
||||
$bindings = [];
|
||||
|
||||
if ($exclude !== 'years' && !empty($filters['years'])) {
|
||||
@@ -670,7 +681,7 @@ class Database
|
||||
// Years — single-valued FK: use full intersection (including own filter).
|
||||
// Clicking one year should fade years that have zero theses in the current result.
|
||||
$matchedYearsIds = array_column($exec("SELECT DISTINCT t.year $baseJoins WHERE $wAll", $bAll), 'year');
|
||||
$allYears = array_column($exec('SELECT DISTINCT year FROM theses WHERE is_published=1 ORDER BY year DESC', []), 'year');
|
||||
$allYears = array_column($exec('SELECT DISTINCT year FROM theses WHERE is_published=1 AND deleted_at IS NULL ORDER BY year DESC', []), 'year');
|
||||
$yearsOut = array_map(fn ($y) => ['value' => $y, 'matched' => in_array($y, $matchedYearsIds, true)], $allYears);
|
||||
|
||||
// AP programs — single-valued FK: use full intersection.
|
||||
@@ -701,7 +712,7 @@ class Database
|
||||
'SELECT DISTINCT tg.name FROM tags tg
|
||||
JOIN thesis_tags tt ON tg.id = tt.tag_id
|
||||
JOIN theses th ON tt.thesis_id = th.id
|
||||
WHERE th.is_published = 1 ORDER BY tg.name',
|
||||
WHERE th.is_published = 1 AND th.deleted_at IS NULL AND tg.deleted_at IS NULL ORDER BY tg.name',
|
||||
[]
|
||||
), 'name');
|
||||
$kwOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedKw, true)], $allKw);
|
||||
@@ -748,7 +759,7 @@ class Database
|
||||
*/
|
||||
public function getAllLanguages(): array
|
||||
{
|
||||
$stmt = $this->pdo->query("SELECT id, UPPER(SUBSTR(name,1,1)) || SUBSTR(name,2) as name, created_at FROM languages ORDER BY name");
|
||||
$stmt = $this->pdo->query("SELECT id, UPPER(SUBSTR(name,1,1)) || SUBSTR(name,2) as name, created_at FROM languages WHERE deleted_at IS NULL ORDER BY name");
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
@@ -772,6 +783,7 @@ class Database
|
||||
CASE WHEN name GLOB '*[À-ÿ]*' THEN 0 ELSE 1 END AS is_ascii
|
||||
FROM languages
|
||||
WHERE LOWER(name) IN ('français', 'anglais', 'néerlandais', 'francais', 'neerlandais')
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY grp, is_ascii"
|
||||
)->fetchAll();
|
||||
|
||||
@@ -852,7 +864,7 @@ class Database
|
||||
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
|
||||
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
|
||||
LEFT JOIN authors a ON ta.author_id = a.id
|
||||
WHERE 1=1';
|
||||
WHERE t.deleted_at IS NULL';
|
||||
|
||||
$params = [];
|
||||
|
||||
@@ -900,7 +912,7 @@ class Database
|
||||
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
|
||||
LEFT JOIN authors a ON ta.author_id = a.id
|
||||
LEFT JOIN access_types at ON t.access_type_id = at.id
|
||||
WHERE 1=1';
|
||||
WHERE t.deleted_at IS NULL';
|
||||
|
||||
$params = [];
|
||||
|
||||
@@ -957,7 +969,7 @@ class Database
|
||||
*/
|
||||
public function getAllYears(): array
|
||||
{
|
||||
$stmt = $this->pdo->query('SELECT DISTINCT year FROM theses ORDER BY year DESC');
|
||||
$stmt = $this->pdo->query('SELECT DISTINCT year FROM theses WHERE deleted_at IS NULL ORDER BY year DESC');
|
||||
return $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
@@ -976,7 +988,8 @@ class Database
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN is_published = 1 THEN 1 ELSE 0 END) AS published,
|
||||
SUM(CASE WHEN is_published = 0 THEN 1 ELSE 0 END) AS pending
|
||||
FROM theses'
|
||||
FROM theses
|
||||
WHERE deleted_at IS NULL'
|
||||
);
|
||||
$row = $stmt->fetch();
|
||||
return [
|
||||
@@ -1157,7 +1170,7 @@ class Database
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ?');
|
||||
$stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ? AND deleted_at IS NULL');
|
||||
$stmt->execute([$name]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
@@ -1167,7 +1180,12 @@ class Database
|
||||
|
||||
$stmt = $this->pdo->prepare('INSERT INTO tags (name) VALUES (?)');
|
||||
$stmt->execute([$name]);
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
$newId = (int)$this->pdo->lastInsertId();
|
||||
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
Audit::log($this, Audit::actor(), 'INSERT', 'tags', $newId, null, ['id' => $newId, 'name' => $name]);
|
||||
|
||||
return $newId;
|
||||
}
|
||||
|
||||
|
||||
@@ -1189,6 +1207,7 @@ class Database
|
||||
COUNT(DISTINCT tt.thesis_id) as thesis_count
|
||||
FROM tags tg
|
||||
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
||||
WHERE tg.deleted_at IS NULL
|
||||
GROUP BY tg.id
|
||||
ORDER BY thesis_count DESC, tg.name COLLATE NOCASE
|
||||
LIMIT 10
|
||||
@@ -1199,7 +1218,7 @@ class Database
|
||||
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 ?
|
||||
WHERE tg.name LIKE ? AND tg.deleted_at IS NULL
|
||||
GROUP BY tg.id
|
||||
ORDER BY tg.name = ? DESC, thesis_count DESC, tg.name COLLATE NOCASE
|
||||
LIMIT 10
|
||||
@@ -1219,6 +1238,7 @@ class Database
|
||||
COUNT(DISTINCT tt.thesis_id) as thesis_count
|
||||
FROM tags tg
|
||||
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
||||
WHERE tg.deleted_at IS NULL
|
||||
GROUP BY tg.id
|
||||
ORDER BY tg.name COLLATE NOCASE
|
||||
');
|
||||
@@ -1234,13 +1254,17 @@ class Database
|
||||
if ($newName === '') {
|
||||
throw new Exception('Le nom du tag ne peut pas être vide.');
|
||||
}
|
||||
// Check uniqueness
|
||||
$stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ? AND id != ?');
|
||||
// Check uniqueness (excluding soft-deleted rows)
|
||||
$stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ? AND id != ? AND deleted_at IS NULL');
|
||||
$stmt->execute([$newName, $id]);
|
||||
if ($stmt->fetch()) {
|
||||
throw new Exception('Un tag avec ce nom existe déjà.');
|
||||
}
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$old = $this->fetchRow('tags', $id);
|
||||
$this->pdo->prepare('UPDATE tags SET name = ? WHERE id = ?')->execute([$newName, $id]);
|
||||
$new = $this->fetchRow('tags', $id);
|
||||
Audit::log($this, Audit::actor(), 'UPDATE', 'tags', $id, $old, $new);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1261,8 +1285,11 @@ class Database
|
||||
')->execute([$targetId, $sourceId]);
|
||||
// Delete the old source rows
|
||||
$this->pdo->prepare('DELETE FROM thesis_tags WHERE tag_id = ?')->execute([$sourceId]);
|
||||
// Delete the source tag itself
|
||||
$this->pdo->prepare('DELETE FROM tags WHERE id = ?')->execute([$sourceId]);
|
||||
// Soft-delete the source tag itself
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$old = $this->fetchRow('tags', $sourceId);
|
||||
$this->pdo->prepare("UPDATE tags SET deleted_at = datetime('now') WHERE id = ?")->execute([$sourceId]);
|
||||
Audit::log($this, Audit::actor(), 'DELETE', 'tags', $sourceId, $old);
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->pdo->rollBack();
|
||||
@@ -1271,11 +1298,14 @@ class Database
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag and all its thesis_tags rows (cascades via FK).
|
||||
* Soft-delete a tag (sets deleted_at).
|
||||
*/
|
||||
public function deleteTag(int $id): void
|
||||
{
|
||||
$this->pdo->prepare('DELETE FROM tags WHERE id = ?')->execute([$id]);
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$old = $this->fetchRow('tags', $id);
|
||||
$this->pdo->prepare("UPDATE tags SET deleted_at = datetime('now') WHERE id = ?")->execute([$id]);
|
||||
Audit::log($this, Audit::actor(), 'DELETE', 'tags', $id, $old);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
@@ -1296,6 +1326,7 @@ class Database
|
||||
COUNT(DISTINCT tl.thesis_id) as thesis_count
|
||||
FROM languages l
|
||||
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
GROUP BY LOWER(l.name)
|
||||
ORDER BY thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
|
||||
LIMIT 10
|
||||
@@ -1307,7 +1338,7 @@ class Database
|
||||
COUNT(DISTINCT tl.thesis_id) as thesis_count
|
||||
FROM languages l
|
||||
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
||||
WHERE LOWER(l.name) LIKE LOWER(?)
|
||||
WHERE LOWER(l.name) LIKE LOWER(?) AND l.deleted_at IS NULL
|
||||
GROUP BY LOWER(l.name)
|
||||
ORDER BY LOWER(MIN(l.name)) = LOWER(?) DESC, thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
|
||||
LIMIT 10
|
||||
@@ -1329,6 +1360,7 @@ class Database
|
||||
COUNT(DISTINCT tl.thesis_id) as thesis_count
|
||||
FROM languages l
|
||||
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
GROUP BY LOWER(l.name)
|
||||
ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE
|
||||
');
|
||||
@@ -1343,6 +1375,7 @@ class Database
|
||||
$dupes = $this->pdo->query('
|
||||
SELECT LOWER(name) as lname, MIN(id) as keep_id
|
||||
FROM languages
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY LOWER(name)
|
||||
HAVING COUNT(*) > 1
|
||||
')->fetchAll();
|
||||
@@ -1361,9 +1394,9 @@ class Database
|
||||
)
|
||||
')->execute([$dup['lname'], $dup['keep_id']]);
|
||||
|
||||
$this->pdo->prepare('
|
||||
DELETE FROM languages WHERE LOWER(name) = ? AND id != ?
|
||||
')->execute([$dup['lname'], $dup['keep_id']]);
|
||||
$this->pdo->prepare(
|
||||
"UPDATE languages SET deleted_at = datetime('now') WHERE LOWER(name) = ? AND id != ?"
|
||||
)->execute([$dup['lname'], $dup['keep_id']]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1376,16 +1409,20 @@ class Database
|
||||
if ($newName === '') {
|
||||
throw new Exception('Le nom de la langue ne peut pas être vide.');
|
||||
}
|
||||
$stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND id != ?');
|
||||
$stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND id != ? AND deleted_at IS NULL');
|
||||
$stmt->execute([$newName, $id]);
|
||||
if ($stmt->fetch()) {
|
||||
throw new Exception('Une langue avec ce nom existe déjà.');
|
||||
}
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$old = $this->fetchRow('languages', $id);
|
||||
$this->pdo->prepare('UPDATE languages SET name = ? WHERE id = ?')->execute([$newName, $id]);
|
||||
$new = $this->fetchRow('languages', $id);
|
||||
Audit::log($this, Audit::actor(), 'UPDATE', 'languages', $id, $old, $new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge sourceId into targetId: reassign all thesis_languages rows, then delete source.
|
||||
* Merge sourceId into targetId: reassign all thesis_languages rows, then soft-delete source.
|
||||
*/
|
||||
public function mergeLanguage(int $sourceId, int $targetId): void
|
||||
{
|
||||
@@ -1399,7 +1436,11 @@ class Database
|
||||
SELECT ?, thesis_id FROM thesis_languages WHERE language_id = ?
|
||||
')->execute([$targetId, $sourceId]);
|
||||
$this->pdo->prepare('DELETE FROM thesis_languages WHERE language_id = ?')->execute([$sourceId]);
|
||||
$this->pdo->prepare('DELETE FROM languages WHERE id = ?')->execute([$sourceId]);
|
||||
// Soft-delete the source language
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$old = $this->fetchRow('languages', $sourceId);
|
||||
$this->pdo->prepare("UPDATE languages SET deleted_at = datetime('now') WHERE id = ?")->execute([$sourceId]);
|
||||
Audit::log($this, Audit::actor(), 'DELETE', 'languages', $sourceId, $old);
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->pdo->rollBack();
|
||||
@@ -1408,11 +1449,14 @@ class Database
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a language and all its thesis_languages rows.
|
||||
* Soft-delete a language (sets deleted_at).
|
||||
*/
|
||||
public function deleteLanguage(int $id): void
|
||||
{
|
||||
$this->pdo->prepare('DELETE FROM languages WHERE id = ?')->execute([$id]);
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$old = $this->fetchRow('languages', $id);
|
||||
$this->pdo->prepare("UPDATE languages SET deleted_at = datetime('now') WHERE id = ?")->execute([$id]);
|
||||
Audit::log($this, Audit::actor(), 'DELETE', 'languages', $id, $old);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1487,10 +1531,14 @@ class Database
|
||||
*/
|
||||
public function setVisibility(int $thesisId, ?int $accessTypeId): void
|
||||
{
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$old = $this->fetchRow('theses', $thesisId);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
);
|
||||
$stmt->execute([$accessTypeId, $thesisId]);
|
||||
$new = $this->fetchRow('theses', $thesisId);
|
||||
Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, $old, $new);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1503,11 +1551,17 @@ class Database
|
||||
if (empty($thesisIds)) {
|
||||
return;
|
||||
}
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$actor = Audit::actor();
|
||||
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
|
||||
$params = array_merge([$accessTypeId], $thesisIds);
|
||||
$this->pdo->prepare(
|
||||
"UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)"
|
||||
)->execute($params);
|
||||
foreach ($thesisIds as $id) {
|
||||
$new = $this->fetchRow('theses', $id);
|
||||
Audit::log($this, $actor, 'UPDATE', 'theses', $id, null, $new);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1515,9 +1569,13 @@ class Database
|
||||
*/
|
||||
public function setPublished(int $thesisId, bool $published): void
|
||||
{
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$old = $this->fetchRow('theses', $thesisId);
|
||||
$this->pdo->prepare(
|
||||
'UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
)->execute([$published ? 1 : 0, $thesisId]);
|
||||
$new = $this->fetchRow('theses', $thesisId);
|
||||
Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, $old, $new);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1529,11 +1587,17 @@ class Database
|
||||
if (empty($thesisIds)) {
|
||||
return;
|
||||
}
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$actor = Audit::actor();
|
||||
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
|
||||
$params = array_merge([$published ? 1 : 0], $thesisIds);
|
||||
$this->pdo->prepare(
|
||||
"UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)"
|
||||
)->execute($params);
|
||||
foreach ($thesisIds as $id) {
|
||||
$new = $this->fetchRow('theses', $id);
|
||||
Audit::log($this, $actor, 'UPDATE', 'theses', $id, null, $new);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1721,8 +1785,8 @@ class Database
|
||||
throw new \InvalidArgumentException('Language name must not be empty.');
|
||||
}
|
||||
|
||||
// 1. Exact lowercase match
|
||||
$stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) LIMIT 1');
|
||||
// 1. Exact lowercase match (skip soft-deleted rows)
|
||||
$stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND deleted_at IS NULL LIMIT 1');
|
||||
$stmt->execute([$name]);
|
||||
$id = $stmt->fetchColumn();
|
||||
if ($id !== false) {
|
||||
@@ -1733,7 +1797,7 @@ class Database
|
||||
// iconv 'ASCII//TRANSLIT' turns é→e, ç→c, etc.
|
||||
$asciiName = @iconv('UTF-8', 'ASCII//TRANSLIT', $name);
|
||||
if ($asciiName !== false && $asciiName !== $name) {
|
||||
$all = $this->pdo->query('SELECT id, name FROM languages')->fetchAll();
|
||||
$all = $this->pdo->query('SELECT id, name FROM languages WHERE deleted_at IS NULL')->fetchAll();
|
||||
foreach ($all as $row) {
|
||||
$rowAscii = @iconv('UTF-8', 'ASCII//TRANSLIT', strtolower($row['name']));
|
||||
if ($rowAscii !== false && $rowAscii === $asciiName) {
|
||||
@@ -1743,7 +1807,12 @@ class Database
|
||||
}
|
||||
|
||||
$this->pdo->prepare('INSERT INTO languages (name) VALUES (?)')->execute([$name]);
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
$newId = (int)$this->pdo->lastInsertId();
|
||||
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
Audit::log($this, Audit::actor(), 'INSERT', 'languages', $newId, null, ['id' => $newId, 'name' => $name]);
|
||||
|
||||
return $newId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1955,6 +2024,11 @@ class Database
|
||||
*/
|
||||
public function updateThesis(int $thesisId, array $data): void
|
||||
{
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId,
|
||||
$this->fetchRow('theses', $thesisId)
|
||||
);
|
||||
|
||||
$stmt = $this->pdo->prepare('
|
||||
UPDATE theses SET
|
||||
title = ?,
|
||||
@@ -2082,29 +2156,28 @@ class Database
|
||||
!empty($data['cc2r']) ? 1 : 0,
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
$newId = (int)$this->pdo->lastInsertId();
|
||||
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$new = $this->fetchRow('theses', $newId);
|
||||
Audit::log($this, Audit::actor(), 'INSERT', 'theses', $newId, null, $new);
|
||||
|
||||
return $newId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single thesis and all its related data (cascade via FK).
|
||||
* Removes thesis files from disk (covers are stored in thesis_files and handled here).
|
||||
* Soft-delete a single thesis (sets deleted_at).
|
||||
*/
|
||||
public function deleteThesis(int $thesisId): void
|
||||
{
|
||||
// Clean up thesis files from disk
|
||||
$files = $this->getThesisFiles($thesisId);
|
||||
foreach ($files as $file) {
|
||||
if (!empty($file['file_path']) && file_exists($file['file_path'])) {
|
||||
@unlink($file['file_path']);
|
||||
}
|
||||
}
|
||||
|
||||
// DB cascade handles junction tables
|
||||
$this->pdo->prepare('DELETE FROM theses WHERE id = ?')->execute([$thesisId]);
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$old = $this->fetchRow('theses', $thesisId);
|
||||
$this->pdo->prepare("UPDATE theses SET deleted_at = datetime('now') WHERE id = ?")->execute([$thesisId]);
|
||||
Audit::log($this, Audit::actor(), 'DELETE', 'theses', $thesisId, $old);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple theses at once.
|
||||
* Soft-delete multiple theses at once.
|
||||
* @param int[] $thesisIds
|
||||
*/
|
||||
public function bulkDeleteTheses(array $thesisIds): void
|
||||
@@ -2113,18 +2186,98 @@ class Database
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up files for each thesis
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$actor = Audit::actor();
|
||||
|
||||
foreach ($thesisIds as $id) {
|
||||
$files = $this->getThesisFiles($id);
|
||||
foreach ($files as $file) {
|
||||
if (!empty($file['file_path']) && file_exists($file['file_path'])) {
|
||||
@unlink($file['file_path']);
|
||||
}
|
||||
$old = $this->fetchRow('theses', $id);
|
||||
$this->pdo->prepare("UPDATE theses SET deleted_at = datetime('now') WHERE id = ?")->execute([$id]);
|
||||
Audit::log($this, $actor, 'DELETE', 'theses', $id, $old);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trashed (soft-deleted) theses for the admin corbeille view.
|
||||
*/
|
||||
public function getTrashedTheses(): array
|
||||
{
|
||||
$stmt = $this->pdo->query('
|
||||
SELECT t.id, t.identifier, t.title, t.subtitle, t.year,
|
||||
GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors,
|
||||
t.deleted_at
|
||||
FROM theses t
|
||||
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
|
||||
LEFT JOIN authors a ON ta.author_id = a.id
|
||||
WHERE t.deleted_at IS NOT NULL
|
||||
GROUP BY t.id
|
||||
ORDER BY t.deleted_at DESC
|
||||
');
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count trashed (soft-deleted) theses.
|
||||
*/
|
||||
public function countTrashedTheses(): int
|
||||
{
|
||||
return (int)$this->pdo->query(
|
||||
'SELECT COUNT(*) FROM theses WHERE deleted_at IS NOT NULL'
|
||||
)->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a soft-deleted thesis.
|
||||
*/
|
||||
public function restoreThesis(int $thesisId): void
|
||||
{
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId,
|
||||
$this->fetchRow('theses', $thesisId)
|
||||
);
|
||||
$this->pdo->prepare('UPDATE theses SET deleted_at = NULL WHERE id = ?')->execute([$thesisId]);
|
||||
$new = $this->fetchRow('theses', $thesisId);
|
||||
Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, null, $new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete a thesis (hard delete — files too).
|
||||
* Only called from the corbeille for truly irreversible cleanup.
|
||||
*/
|
||||
public function hardDeleteThesis(int $thesisId): void
|
||||
{
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
$old = $this->fetchRow('theses', $thesisId);
|
||||
|
||||
// Clean up thesis files from disk
|
||||
$files = $this->getThesisFiles($thesisId);
|
||||
foreach ($files as $file) {
|
||||
if (!empty($file['file_path']) && file_exists($file['file_path'])) {
|
||||
@unlink($file['file_path']);
|
||||
}
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
|
||||
$this->pdo->prepare("DELETE FROM theses WHERE id IN ($placeholders)")->execute($thesisIds);
|
||||
$this->pdo->prepare('DELETE FROM theses WHERE id = ?')->execute([$thesisId]);
|
||||
Audit::log($this, Audit::actor(), 'DELETE', 'theses', $thesisId, $old);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a soft-deleted tag.
|
||||
*/
|
||||
public function restoreTag(int $id): void
|
||||
{
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
Audit::log($this, Audit::actor(), 'UPDATE', 'tags', $id, $this->fetchRow('tags', $id));
|
||||
$this->pdo->prepare('UPDATE tags SET deleted_at = NULL WHERE id = ?')->execute([$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a soft-deleted language.
|
||||
*/
|
||||
public function restoreLanguage(int $id): void
|
||||
{
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
Audit::log($this, Audit::actor(), 'UPDATE', 'languages', $id, $this->fetchRow('languages', $id));
|
||||
$this->pdo->prepare('UPDATE languages SET deleted_at = NULL WHERE id = ?')->execute([$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2202,7 +2355,7 @@ class Database
|
||||
public function deleteThesisFile(int $fileId, int $thesisId): ?string
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT file_path FROM thesis_files WHERE id = ? AND thesis_id = ? LIMIT 1'
|
||||
'SELECT * FROM thesis_files WHERE id = ? AND thesis_id = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$fileId, $thesisId]);
|
||||
$row = $stmt->fetch();
|
||||
@@ -2210,6 +2363,10 @@ class Database
|
||||
return null;
|
||||
}
|
||||
$this->pdo->prepare('DELETE FROM thesis_files WHERE id = ?')->execute([$fileId]);
|
||||
|
||||
require_once __DIR__ . '/Audit.php';
|
||||
Audit::log($this, Audit::actor(), 'DELETE', 'thesis_files', $fileId, $row);
|
||||
|
||||
return $row['file_path'];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user