diff --git a/.env.example b/.env.example
index 3c93f6c..ae01e25 100644
--- a/.env.example
+++ b/.env.example
@@ -63,3 +63,13 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
+
+# ── Mises à jour automatiques ─────────────────────────────────────────────────
+# Serveur Gitea hébergeant les releases publiques
+GITEA_URL=https://git.barbel.synology.me
+GITEA_OWNER=CGL
+GITEA_REPO=mesreleves-php
+# Si true, la mise à jour est appliquée automatiquement lors de la vérification planifiée
+AUTO_UPDATE=false
+# Nombre de sauvegardes PostgreSQL conservées avant suppression
+UPDATE_BACKUPS_TO_KEEP=5
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..3eefcb9
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.0.0
diff --git a/app/Console/Commands/ApplyUpdateCommand.php b/app/Console/Commands/ApplyUpdateCommand.php
new file mode 100644
index 0000000..5cd3878
--- /dev/null
+++ b/app/Console/Commands/ApplyUpdateCommand.php
@@ -0,0 +1,60 @@
+getInstalledVersion();
+ $this->line("Version installée : v{$installed} ");
+
+ $this->line("Vérification des mises à jour...");
+ $latest = $updates->fetchLatestRelease(useCache: false);
+
+ if (! $latest) {
+ $this->error("Impossible de contacter le serveur de mises à jour.");
+ return self::FAILURE;
+ }
+
+ if (! version_compare($latest['version'], $installed, '>')) {
+ $this->info("L'application est déjà à jour (v{$installed}).");
+ return self::SUCCESS;
+ }
+
+ $this->info("Mise à jour disponible : v{$installed} → v{$latest['version']} ");
+
+ if ($latest['body']) {
+ $this->line($latest['body']);
+ }
+
+ if ($this->option('check')) {
+ return self::SUCCESS;
+ }
+
+ if (! $this->option('force') && ! $this->confirm('Procéder à la mise à jour ?', true)) {
+ return self::SUCCESS;
+ }
+
+ $this->line("");
+
+ try {
+ $updates->applyUpdate($latest, fn ($msg) => $this->line($msg));
+ } catch (\Exception $e) {
+ $this->error("La mise à jour a échoué : " . $e->getMessage());
+ $this->warn("Vérifiez les sauvegardes dans storage/app/backups/ pour une restauration manuelle.");
+ return self::FAILURE;
+ }
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/CheckUpdateCommand.php b/app/Console/Commands/CheckUpdateCommand.php
new file mode 100644
index 0000000..0f01ce8
--- /dev/null
+++ b/app/Console/Commands/CheckUpdateCommand.php
@@ -0,0 +1,46 @@
+getInstalledVersion();
+ $this->line("Version installée : v{$installed} ");
+
+ $this->line("Interrogation du serveur de mises à jour...");
+ $latest = $updates->fetchLatestRelease(useCache: ! $this->option('force'));
+
+ if (! $latest) {
+ $this->warn("Impossible de contacter le serveur de mises à jour.");
+ return self::FAILURE;
+ }
+
+ if (version_compare($latest['version'], $installed, '>')) {
+ $this->info("Nouvelle version disponible : v{$latest['version']}");
+ if ($latest['body']) {
+ $this->line("Notes de version : ");
+ $this->line($latest['body']);
+ }
+ $this->line("");
+ $this->line("Pour mettre à jour : php artisan app:update ");
+
+ if (config('update.auto_update')) {
+ $this->line("AUTO_UPDATE=true détecté — application automatique...");
+ $this->call('app:update', ['--force' => true]);
+ }
+
+ return self::SUCCESS;
+ }
+
+ $this->info("L'application est à jour (v{$installed}).");
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/RollbackCommand.php b/app/Console/Commands/RollbackCommand.php
new file mode 100644
index 0000000..28f5be3
--- /dev/null
+++ b/app/Console/Commands/RollbackCommand.php
@@ -0,0 +1,65 @@
+listBackups();
+
+ if (empty($backups)) {
+ $this->warn("Aucune sauvegarde disponible dans storage/app/backups/.");
+ return self::FAILURE;
+ }
+
+ // --list : afficher la liste et sortir
+ if ($this->option('list')) {
+ $rows = array_map(fn ($b) => [
+ basename($b),
+ $this->humanSize(filesize($b)),
+ date('d/m/Y H:i:s', filemtime($b)),
+ ], $backups);
+ $this->table(['Fichier', 'Taille', 'Date'], $rows);
+ return self::SUCCESS;
+ }
+
+ // Sélection interactive
+ $choices = array_map('basename', $backups);
+ $choice = $this->choice('Choisir la sauvegarde à restaurer :', $choices, 0);
+ $path = storage_path("app/backups/{$choice}");
+
+ $this->warn("Sauvegarde sélectionnée : {$choice}");
+ $this->warn("ATTENTION : cette opération remplace la base de données actuelle.");
+
+ if (! $this->confirm('Continuer ?')) {
+ $this->line("Annulé.");
+ return self::SUCCESS;
+ }
+
+ try {
+ $updates->restoreBackup($path);
+ $this->info("Base de données restaurée avec succès.");
+ } catch (\Exception $e) {
+ $this->error("Restauration échouée : " . $e->getMessage());
+ return self::FAILURE;
+ }
+
+ return self::SUCCESS;
+ }
+
+ private function humanSize(int $bytes): string
+ {
+ if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 1) . ' Mo';
+ if ($bytes >= 1_024) return round($bytes / 1_024, 0) . ' Ko';
+ return $bytes . ' o';
+ }
+}
diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php
index 16f184f..a26875b 100644
--- a/app/Http/Controllers/Admin/DashboardController.php
+++ b/app/Http/Controllers/Admin/DashboardController.php
@@ -7,12 +7,18 @@ use App\Http\Controllers\Controller;
use App\Models\Releve;
use App\Models\Source;
use App\Models\User;
+use App\Services\UpdateService;
use Illuminate\View\View;
class DashboardController extends Controller
{
- public function index(): View
+ public function index(UpdateService $updates): View
{
+ $installedVersion = $updates->getInstalledVersion();
+ $latestRelease = $updates->fetchLatestRelease(); // depuis le cache Redis
+ $updateAvailable = $latestRelease
+ && version_compare($latestRelease['version'], $installedVersion, '>');
+
// Compteurs sources par statut
$sourcesByStatus = Source::selectRaw('status, count(*) as total')
->groupBy('status')
@@ -54,7 +60,8 @@ class DashboardController extends Controller
return view('admin.dashboard', compact(
'sourcesByStatus', 'totalSources', 'totalReleves',
'usersByRole', 'totalUsers',
- 'sourcesAValider', 'relevesRecents', 'activiteMensuelle'
+ 'sourcesAValider', 'relevesRecents', 'activiteMensuelle',
+ 'installedVersion', 'latestRelease', 'updateAvailable'
));
}
}
diff --git a/app/Services/UpdateService.php b/app/Services/UpdateService.php
new file mode 100644
index 0000000..db33362
--- /dev/null
+++ b/app/Services/UpdateService.php
@@ -0,0 +1,297 @@
+get($url);
+
+ if (! $response->successful()) {
+ return null;
+ }
+
+ $data = $response->json();
+ $version = ltrim($data['tag_name'] ?? '', 'v');
+
+ $downloadUrl = null;
+ foreach ($data['assets'] ?? [] as $asset) {
+ if (str_ends_with($asset['name'] ?? '', '.tar.gz')) {
+ $downloadUrl = $asset['browser_download_url'];
+ break;
+ }
+ }
+
+ if (! $version || ! $downloadUrl) {
+ return null;
+ }
+
+ $release = [
+ 'version' => $version,
+ 'tag' => $data['tag_name'],
+ 'name' => $data['name'] ?? "v{$version}",
+ 'download_url' => $downloadUrl,
+ 'published_at' => $data['published_at'] ?? null,
+ 'body' => $data['body'] ?? '',
+ ];
+
+ Cache::put(self::CACHE_KEY, $release, self::CACHE_TTL);
+ return $release;
+
+ } catch (\Exception $e) {
+ Log::warning('UpdateService: impossible de contacter le serveur de mises à jour.', [
+ 'error' => $e->getMessage(),
+ ]);
+ return null;
+ }
+ }
+
+ public function getLatestIfNewer(): ?array
+ {
+ $latest = $this->fetchLatestRelease();
+ if (! $latest) {
+ return null;
+ }
+
+ return version_compare($latest['version'], $this->getInstalledVersion(), '>') ? $latest : null;
+ }
+
+ // ─── Téléchargement ─────────────────────────────────────────────────────
+
+ public function download(array $release): string
+ {
+ $path = sys_get_temp_dir() . "/mesreleves-{$release['version']}.tar.gz";
+
+ $response = Http::timeout(300)->sink($path)->get($release['download_url']);
+
+ if (! $response->successful() || ! file_exists($path) || filesize($path) === 0) {
+ @unlink($path);
+ throw new RuntimeException("Téléchargement échoué (HTTP {$response->status()}).");
+ }
+
+ return $path;
+ }
+
+ // ─── Application de la mise à jour ──────────────────────────────────────
+
+ public function applyUpdate(array $release, callable $log = null): void
+ {
+ $log ??= static fn ($msg) => null;
+ $version = $release['version'];
+ $appPath = base_path();
+
+ // 1. Sauvegarde BDD
+ $log("Sauvegarde de la base de données...");
+ $backupPath = $this->backupDatabase($version);
+ $log(" → {$backupPath}");
+
+ // 2. Téléchargement
+ $log("Téléchargement de mesreleves-{$version}.tar.gz...");
+ $archivePath = $this->download($release);
+ $log(" → {$archivePath} (" . $this->humanSize(filesize($archivePath)) . ")");
+
+ // 3. Maintenance
+ $log("Activation du mode maintenance...");
+ Artisan::call('down');
+
+ try {
+ // 4. Extraction
+ $log("Extraction de l'archive...");
+ $tmpDir = sys_get_temp_dir() . "/mesreleves-update-{$version}";
+ if (is_dir($tmpDir)) {
+ $this->exec("rm -rf " . escapeshellarg($tmpDir));
+ }
+ mkdir($tmpDir, 0755, true);
+ $this->exec("tar -xzf " . escapeshellarg($archivePath) . " -C " . escapeshellarg($tmpDir));
+
+ $dirs = glob($tmpDir . '/*/');
+ if (empty($dirs)) {
+ throw new RuntimeException("L'archive est vide ou mal formée.");
+ }
+ $srcDir = rtrim($dirs[0], '/');
+
+ // 5. Synchronisation des fichiers (préserve .env, storage, vendor)
+ $log("Synchronisation des fichiers...");
+ $this->exec(sprintf(
+ 'rsync -a --delete --exclude=".env" --exclude="storage/" --exclude="vendor/" %s %s',
+ escapeshellarg($srcDir . '/'),
+ escapeshellarg($appPath . '/'),
+ ));
+
+ // 6. Dépendances Composer (prod)
+ $log("Installation des dépendances (composer)...");
+ $this->exec('composer install --no-dev --optimize-autoloader --quiet', $appPath);
+
+ // 7. Mise à jour du fichier VERSION
+ file_put_contents($appPath . '/VERSION', $version . PHP_EOL);
+
+ // 8. Migrations
+ $log("Migration de la base de données...");
+ Artisan::call('migrate', ['--force' => true]);
+ $log(trim(Artisan::output()));
+
+ // 9. Nettoyage et optimisation des caches
+ $log("Optimisation...");
+ Artisan::call('optimize:clear');
+ Artisan::call('optimize');
+
+ // 10. Rechargement php-fpm (opcache)
+ $log("Rechargement php-fpm...");
+ $this->reloadPhpFpm();
+
+ // 11. Purge des anciennes sauvegardes
+ $this->pruneBackups();
+
+ // 12. Nettoyage temporaire
+ $this->exec("rm -rf " . escapeshellarg($tmpDir));
+ @unlink($archivePath);
+
+ Cache::forget(self::CACHE_KEY);
+ $log("✓ Mise à jour vers v{$version} réussie.");
+
+ } catch (\Exception $e) {
+ $log("✗ Erreur : " . $e->getMessage());
+ $log(" La base de données peut être restaurée depuis : {$backupPath}");
+ throw $e;
+ } finally {
+ Artisan::call('up');
+ }
+ }
+
+ // ─── Sauvegardes ────────────────────────────────────────────────────────
+
+ public function listBackups(): array
+ {
+ $dir = storage_path('app/backups');
+ $backups = glob($dir . '/db-*.sql') ?: [];
+ rsort($backups);
+ return $backups;
+ }
+
+ public function restoreBackup(string $path): void
+ {
+ if (! file_exists($path)) {
+ throw new RuntimeException("Fichier introuvable : {$path}");
+ }
+
+ $host = config('database.connections.pgsql.host');
+ $port = config('database.connections.pgsql.port', 5432);
+ $db = config('database.connections.pgsql.database');
+ $user = config('database.connections.pgsql.username');
+ $password = config('database.connections.pgsql.password');
+
+ $cmd = sprintf(
+ 'PGPASSWORD=%s psql -h %s -p %s -U %s %s < %s',
+ escapeshellarg($password),
+ escapeshellarg($host),
+ (int) $port,
+ escapeshellarg($user),
+ escapeshellarg($db),
+ escapeshellarg($path),
+ );
+
+ Artisan::call('down');
+ $this->exec($cmd);
+ Artisan::call('up');
+ }
+
+ // ─── Méthodes privées ───────────────────────────────────────────────────
+
+ private function backupDatabase(string $version): string
+ {
+ $dir = storage_path('app/backups');
+ if (! is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+
+ $filename = "db-before-{$version}-" . date('Ymd-His') . ".sql";
+ $path = "{$dir}/{$filename}";
+
+ $host = config('database.connections.pgsql.host');
+ $port = config('database.connections.pgsql.port', 5432);
+ $db = config('database.connections.pgsql.database');
+ $user = config('database.connections.pgsql.username');
+ $password = config('database.connections.pgsql.password');
+
+ $cmd = sprintf(
+ 'PGPASSWORD=%s pg_dump -h %s -p %s -U %s %s > %s',
+ escapeshellarg($password),
+ escapeshellarg($host),
+ (int) $port,
+ escapeshellarg($user),
+ escapeshellarg($db),
+ escapeshellarg($path),
+ );
+
+ $this->exec($cmd);
+
+ if (! file_exists($path) || filesize($path) === 0) {
+ throw new RuntimeException("La sauvegarde PostgreSQL a échoué (pg_dump disponible dans le container ?)");
+ }
+
+ return $path;
+ }
+
+ private function pruneBackups(): void
+ {
+ $backups = $this->listBackups();
+ $keep = (int) config('update.backups_to_keep', 5);
+
+ foreach (array_slice($backups, $keep) as $old) {
+ @unlink($old);
+ }
+ }
+
+ private function reloadPhpFpm(): void
+ {
+ // Envoie USR2 au process php-fpm (rechargement gracieux sans downtime)
+ // PID 1 = php-fpm master dans le container Docker
+ @shell_exec('kill -USR2 1 2>/dev/null || true');
+ }
+
+ private function exec(string $cmd, ?string $cwd = null): string
+ {
+ $fullCmd = $cwd ? "cd " . escapeshellarg($cwd) . " && {$cmd}" : $cmd;
+ $output = shell_exec("{$fullCmd} 2>&1") ?? '';
+ return $output;
+ }
+
+ private function humanSize(int $bytes): string
+ {
+ if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 1) . ' Mo';
+ if ($bytes >= 1_024) return round($bytes / 1_024, 0) . ' Ko';
+ return $bytes . ' o';
+ }
+}
diff --git a/bin/build-release.sh b/bin/build-release.sh
new file mode 100755
index 0000000..8b19016
--- /dev/null
+++ b/bin/build-release.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+# ─────────────────────────────────────────────────────────────────────────────
+# MesRelevés — Script de construction d'une archive de release
+#
+# Usage : bin/build-release.sh
+#
+# Prérequis : node/npm (pour les assets), rsync, tar
+# Produit : mesreleves-X.Y.Z.tar.gz + mesreleves-X.Y.Z.tar.gz.sha256
+# ─────────────────────────────────────────────────────────────────────────────
+set -euo pipefail
+
+# Se positionner à la racine du projet
+cd "$(git rev-parse --show-toplevel)"
+
+VERSION=$(cat VERSION | tr -d '[:space:]')
+ARCHIVE="mesreleves-${VERSION}.tar.gz"
+
+echo "──────────────────────────────────────────────"
+echo " MesRelevés — Build de la release v${VERSION}"
+echo "──────────────────────────────────────────────"
+
+# ── 1. Vérifications ──────────────────────────────────────────────────────────
+if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null; then
+ :
+else
+ echo "AVERTISSEMENT : des modifications non commitées seront incluses."
+fi
+
+# ── 2. Build des assets frontend ─────────────────────────────────────────────
+echo "→ Build des assets frontend (npm run build)..."
+npm run build --silent
+
+# ── 3. Copie dans un répertoire temporaire (sans vendor, sans fichiers dev) ──
+TMPDIR=$(mktemp -d)
+BUILDDIR="${TMPDIR}/mesreleves-${VERSION}"
+trap 'rm -rf "${TMPDIR}"' EXIT
+
+echo "→ Copie des fichiers dans ${BUILDDIR}..."
+rsync -a \
+ --exclude='.git/' \
+ --exclude='.env' \
+ --exclude='node_modules/' \
+ --exclude='vendor/' \
+ --exclude='storage/logs/' \
+ --exclude='storage/framework/cache/' \
+ --exclude='storage/framework/sessions/' \
+ --exclude='storage/framework/views/' \
+ --exclude='tests/' \
+ --exclude='phpunit.xml' \
+ --exclude='.editorconfig' \
+ --exclude='.gitattributes' \
+ --exclude='.gitignore' \
+ --exclude='*.tar.gz' \
+ --exclude='*.sha256' \
+ --exclude='.phpunit.result.cache' \
+ . "${BUILDDIR}/"
+
+# ── 4. Création de l'archive ─────────────────────────────────────────────────
+echo "→ Création de l'archive ${ARCHIVE}..."
+tar -czf "${ARCHIVE}" -C "${TMPDIR}" "mesreleves-${VERSION}/"
+
+# ── 5. Somme de contrôle SHA-256 ─────────────────────────────────────────────
+sha256sum "${ARCHIVE}" > "${ARCHIVE}.sha256"
+CHECKSUM=$(awk '{print $1}' "${ARCHIVE}.sha256")
+
+# ── Résumé ────────────────────────────────────────────────────────────────────
+SIZE=$(du -sh "${ARCHIVE}" | cut -f1)
+echo ""
+echo "✓ Archive créée : ${ARCHIVE} (${SIZE})"
+echo " SHA-256 : ${CHECKSUM}"
+echo ""
+echo "Prochaines étapes :"
+echo " 1. git tag v${VERSION} && git push origin v${VERSION}"
+echo " 2. Créer une release sur Gitea (tag v${VERSION})"
+echo " 3. Joindre ${ARCHIVE} et ${ARCHIVE}.sha256 à la release"
diff --git a/config/update.php b/config/update.php
new file mode 100644
index 0000000..c6ced2d
--- /dev/null
+++ b/config/update.php
@@ -0,0 +1,23 @@
+ env('GITEA_URL', 'https://git.barbel.synology.me'),
+ 'gitea_owner' => env('GITEA_OWNER', 'CGL'),
+ 'gitea_repo' => env('GITEA_REPO', 'mesreleves-php'),
+
+ /*
+ * Si true, la mise à jour est téléchargée et appliquée automatiquement
+ * lors de la vérification quotidienne planifiée.
+ * Par défaut false : seule une notification admin est générée.
+ */
+ 'auto_update' => env('AUTO_UPDATE', false),
+
+ /*
+ * Nombre de sauvegardes PostgreSQL à conserver dans storage/app/backups/.
+ */
+ 'backups_to_keep' => env('UPDATE_BACKUPS_TO_KEEP', 5),
+];
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 46190bb..8e5da65 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -26,6 +26,9 @@ services:
redis:
condition: service_started
volumes:
+ # Le code est monté depuis l'hôte : les mises à jour ne nécessitent pas de rebuild image
+ - .:/var/www/html
+ # Le storage est un volume nommé (uploads, logs, sessions — persisté indépendamment du code)
- app_storage:/var/www/html/storage
networks:
- internal
@@ -39,8 +42,8 @@ services:
- "443:443"
volumes:
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- - app_storage:/var/www/html/storage:ro
- ./public:/var/www/html/public:ro
+ - app_storage:/var/www/html/storage:ro
depends_on:
- app
networks:
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 7902702..7a8007d 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,29 +1,23 @@
FROM php:8.5-fpm-alpine
+# Runtime : extensions PHP + outils nécessaires aux mises à jour
RUN apk add --no-cache \
postgresql-dev \
- redis \
+ postgresql-client \
nodejs \
npm \
+ rsync \
&& docker-php-ext-install pdo pdo_pgsql opcache
-# Installer Composer
+# Composer (runtime, pas de copie du code dans l'image)
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+# Entrypoint : composer install + caches au démarrage du container
+COPY docker/entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
WORKDIR /var/www/html
-COPY composer.json composer.lock ./
-RUN composer install --no-dev --optimize-autoloader --no-scripts
-
-COPY . .
-RUN composer run-script post-autoload-dump \
- && npm ci \
- && npm run build \
- && rm -rf node_modules \
- && php artisan config:cache \
- && php artisan route:cache \
- && php artisan view:cache \
- && chown -R www-data:www-data storage bootstrap/cache
-
EXPOSE 9000
+ENTRYPOINT ["/entrypoint.sh"]
CMD ["php-fpm"]
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100755
index 0000000..2930646
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+set -e
+
+# Installer les dépendances Composer si nécessaire
+if [ ! -f vendor/autoload.php ] || [ composer.lock -nt vendor/autoload.php ] 2>/dev/null; then
+ echo "[entrypoint] composer install --no-dev ..."
+ composer install --no-dev --optimize-autoloader --quiet
+fi
+
+# Générer la clé applicative si absente
+if grep -q "^APP_KEY=$" .env 2>/dev/null; then
+ echo "[entrypoint] Génération de la clé applicative..."
+ php artisan key:generate --force
+fi
+
+# Caches (silencieux si pas encore de .env complet)
+php artisan config:cache 2>/dev/null || true
+php artisan route:cache 2>/dev/null || true
+php artisan view:cache 2>/dev/null || true
+
+# Permissions storage
+chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true
+chmod -R 775 storage bootstrap/cache 2>/dev/null || true
+
+echo "[entrypoint] Démarrage php-fpm..."
+exec "$@"
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..4113106
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+# ─────────────────────────────────────────────────────────────────────────────
+# MesRelevés — Installation initiale (déploiement Docker)
+#
+# Usage : ./install.sh
+#
+# Prérequis : Docker + Docker Compose v2
+# ─────────────────────────────────────────────────────────────────────────────
+set -euo pipefail
+
+COMPOSE="docker compose -f docker-compose.prod.yml"
+
+echo "────────────────────────────────────────────"
+echo " MesRelevés — Installation"
+echo "────────────────────────────────────────────"
+
+# ── Prérequis ─────────────────────────────────────────────────────────────────
+command -v docker >/dev/null 2>&1 || { echo "Erreur : Docker n'est pas installé."; exit 1; }
+docker compose version >/dev/null 2>&1 || { echo "Erreur : Docker Compose v2 requis."; exit 1; }
+
+# ── Fichier .env ──────────────────────────────────────────────────────────────
+if [ ! -f .env ]; then
+ cp .env.example .env
+ echo ""
+ echo "→ Fichier .env créé depuis .env.example."
+ echo " Éditez .env avec vos paramètres (DB_PASSWORD, MAIL_*, APP_URL, etc.)"
+ echo " puis relancez ce script."
+ echo ""
+ exit 0
+fi
+
+# Vérification DB_PASSWORD obligatoire
+if grep -qE "^DB_PASSWORD=$" .env; then
+ echo "Erreur : DB_PASSWORD n'est pas défini dans .env."
+ exit 1
+fi
+
+# ── Structure storage ─────────────────────────────────────────────────────────
+mkdir -p storage/{app/backups,framework/{cache,sessions,views},logs}
+mkdir -p bootstrap/cache
+
+# ── Démarrage des services ────────────────────────────────────────────────────
+echo "→ Construction de l'image Docker..."
+$COMPOSE build
+
+echo "→ Démarrage des services (db, redis, app, nginx)..."
+$COMPOSE up -d
+
+echo "→ Attente de la base de données..."
+until $COMPOSE exec -T db pg_isready -U "${DB_USERNAME:-mesreleves}" >/dev/null 2>&1; do
+ sleep 2
+done
+echo " Base de données prête."
+
+# Attendre que l'entrypoint termine (composer install, caches)
+sleep 8
+
+# ── Migrations ────────────────────────────────────────────────────────────────
+echo "→ Migration de la base de données..."
+$COMPOSE exec app php artisan migrate --force
+
+# ── Lien storage ──────────────────────────────────────────────────────────────
+$COMPOSE exec app php artisan storage:link 2>/dev/null || true
+
+# ── Tâche planifiée (cron sur l'hôte) ────────────────────────────────────────
+CRON_CMD="* * * * * cd $(pwd) && docker compose -f docker-compose.prod.yml exec -T app php artisan schedule:run >> /dev/null 2>&1"
+echo ""
+echo "→ Pour activer les tâches planifiées (vérification automatique des mises à jour),"
+echo " ajoutez cette ligne à votre crontab (crontab -e) :"
+echo ""
+echo " ${CRON_CMD}"
+echo ""
+
+# ── Résumé ────────────────────────────────────────────────────────────────────
+VERSION=$(cat VERSION 2>/dev/null || echo "?")
+echo "────────────────────────────────────────────"
+echo "✓ MesRelevés v${VERSION} installé avec succès !"
+echo ""
+echo " URL : $(grep APP_URL .env | cut -d= -f2)"
+echo ""
+echo "Commandes utiles :"
+echo " Voir les logs : docker compose -f docker-compose.prod.yml logs -f app"
+echo " Vérifier updates : docker compose -f docker-compose.prod.yml exec app php artisan app:check-update"
+echo " Mettre à jour : docker compose -f docker-compose.prod.yml exec app php artisan app:update"
+echo "────────────────────────────────────────────"
diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php
index d510990..a801a7a 100644
--- a/resources/views/admin/dashboard.blade.php
+++ b/resources/views/admin/dashboard.blade.php
@@ -5,6 +5,32 @@
+ {{-- Bandeau mise à jour disponible --}}
+ @if($updateAvailable)
+
+
+
+
+
+
+
+ Mise à jour disponible : v{{ $latestRelease['version'] }}
+ (installé : v{{ $installedVersion }})
+
+
+ docker compose exec app php artisan app:update
+
+
+
+ @if($latestRelease['published_at'])
+
+ Publié {{ \Carbon\Carbon::parse($latestRelease['published_at'])->diffForHumans() }}
+
+ @endif
+
+ @endif
+
{{-- Compteurs globaux --}}
@php
@@ -135,5 +161,17 @@
@endforelse
+ {{-- Version --}}
+
+
MesRelevés v{{ $installedVersion }}
+ @if(! $updateAvailable)
+
+
+
+
+ à jour
+
+ @endif
+
diff --git a/routes/console.php b/routes/console.php
index 3c9adf1..bb643e8 100644
--- a/routes/console.php
+++ b/routes/console.php
@@ -1,8 +1,11 @@
comment(Inspiring::quote());
-})->purpose('Display an inspiring quote');
+// Vérification quotidienne des mises à jour disponibles.
+// Si AUTO_UPDATE=true dans .env, la mise à jour est aussi appliquée automatiquement.
+// Pour activer : ajouter à crontab (voir install.sh)
+Schedule::command('app:check-update')
+ ->daily()
+ ->withoutOverlapping()
+ ->runInBackground();