diff --git a/README.md b/README.md
index 07349ca..0215afd 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,8 @@ comptes) ou ouvrir les inscriptions publiques depuis le menu Paramètres.
- Catalogue de flux façon "market" : une sélection de flux embarquée avec l'application
([resources/data/bloomflux.json](resources/data/bloomflux.json)), à ajouter en un clic.
- Multi-comptes : le propriétaire crée des comptes ou ouvre/ferme les inscriptions d'un clic.
+- Notifications personnelles : chaque utilisateur configure ses propres alertes **Discord** ou
+ **webhook générique**, pour tous ses flux ou seulement certains.
- Interface disponible en **français, anglais, espagnol et allemand** (sélecteur en haut de page,
préférence mémorisée par utilisateur).
- Récupération périodique des flux via une tâche planifiée.
@@ -37,9 +39,13 @@ comptes) ou ouvrir les inscriptions publiques depuis le menu Paramètres.
|---|---|
|  |  |
-| Catalogue de flux | Mode sombre |
+| Catalogue de flux | Notifieurs |
|---|---|
-|  |  |
+|  |  |
+
+| Mode sombre |
+|---|
+|  |
## Stack technique
@@ -82,6 +88,16 @@ publiques se referment automatiquement après.
Relancez simplement `bash scriptsite.sh` : il récupère les derniers changements et reconstruit
la stack sans toucher à vos données.
+### Réinitialisation
+
+Pour repartir d'une instance vierge (efface comptes, flux, articles et notifieurs) sans toucher
+au code, au `.env` ni aux images Docker :
+
+```bash
+bash reset.sh # confirmation interactive requise
+bash reset.sh --yes # sans confirmation
+```
+
### Désinstallation
```bash
@@ -126,6 +142,18 @@ l'enrichir, éditez le fichier et redéployez — aucun service externe n'est n
{ "title": "Nom du flux", "url": "https://exemple.com/rss.xml", "category": "Tech", "lang": "fr", "description": "Courte description." }
```
+### Notifications (Discord / webhook)
+
+Chaque utilisateur configure ses propres notifieurs depuis le menu **Notifieurs** :
+
+- **Discord** : collez l'URL d'un webhook de salon Discord (Paramètres du salon → Intégrations →
+ Webhooks) ; BloomFeed envoie un embed (titre, lien, extrait) à chaque nouvel article.
+- **Webhook générique** : n'importe quelle URL recevant un `POST` JSON
+ `{ "event": "article.created", "feed": "...", "article": { "title", "url", "excerpt", "published_at" } }`.
+
+Chaque notifieur a une **portée** : tous les flux de l'utilisateur, ou une sélection précise de
+flux. Un bouton "Tester" envoie une notification factice pour valider l'URL avant de compter dessus.
+
## Développement local
Prérequis : PHP 8.3+ et [Composer](https://getcomposer.org).
diff --git a/app/Http/Controllers/NotifierController.php b/app/Http/Controllers/NotifierController.php
new file mode 100644
index 0000000..b91274f
--- /dev/null
+++ b/app/Http/Controllers/NotifierController.php
@@ -0,0 +1,103 @@
+user()->notifiers()->with('feeds')->orderBy('created_at')->get();
+ $feeds = $request->user()->feeds()->orderBy('title')->get();
+
+ return view('notifiers', [
+ 'notifiers' => $notifiers,
+ 'feeds' => $feeds,
+ ]);
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $validated = $this->validatePayload($request);
+
+ $notifier = $request->user()->notifiers()->create([
+ 'type' => $validated['type'],
+ 'name' => $validated['name'],
+ 'url' => $validated['url'],
+ 'scope' => $validated['scope'],
+ ]);
+
+ if ($validated['scope'] === Notifier::SCOPE_FEEDS) {
+ $notifier->feeds()->sync($validated['feed_ids'] ?? []);
+ }
+
+ return redirect()->route('notifiers')->with('status', __('app.notifier_created'));
+ }
+
+ public function update(Request $request, Notifier $notifier): RedirectResponse
+ {
+ $this->authorize('update', $notifier);
+
+ $validated = $this->validatePayload($request);
+
+ $notifier->update([
+ 'type' => $validated['type'],
+ 'name' => $validated['name'],
+ 'url' => $validated['url'],
+ 'scope' => $validated['scope'],
+ ]);
+
+ $notifier->feeds()->sync($validated['scope'] === Notifier::SCOPE_FEEDS ? ($validated['feed_ids'] ?? []) : []);
+
+ return redirect()->route('notifiers')->with('status', __('app.notifier_updated'));
+ }
+
+ public function toggle(Request $request, Notifier $notifier): RedirectResponse
+ {
+ $this->authorize('update', $notifier);
+
+ $notifier->update(['enabled' => ! $notifier->enabled]);
+
+ return redirect()->route('notifiers');
+ }
+
+ public function test(Notifier $notifier, NotifierDispatchService $dispatcher): RedirectResponse
+ {
+ $this->authorize('update', $notifier);
+
+ $ok = $dispatcher->sendTest($notifier);
+
+ return redirect()->route('notifiers')
+ ->with('status', $ok ? __('app.notifier_test_ok') : __('app.notifier_test_failed'));
+ }
+
+ public function destroy(Notifier $notifier): RedirectResponse
+ {
+ $this->authorize('delete', $notifier);
+
+ $notifier->delete();
+
+ return redirect()->route('notifiers')->with('status', __('app.notifier_deleted'));
+ }
+
+ private function validatePayload(Request $request): array
+ {
+ return $request->validate([
+ 'type' => ['required', 'in:'.Notifier::TYPE_DISCORD.','.Notifier::TYPE_WEBHOOK],
+ 'name' => ['required', 'string', 'max:255'],
+ 'url' => ['required', 'url', 'max:2048'],
+ 'scope' => ['required', 'in:'.Notifier::SCOPE_ALL.','.Notifier::SCOPE_FEEDS],
+ 'feed_ids' => ['array'],
+ 'feed_ids.*' => [
+ 'integer',
+ Rule::exists('feeds', 'id')->where('user_id', $request->user()->id),
+ ],
+ ]);
+ }
+}
diff --git a/app/Models/Notifier.php b/app/Models/Notifier.php
new file mode 100644
index 0000000..eed3f9c
--- /dev/null
+++ b/app/Models/Notifier.php
@@ -0,0 +1,55 @@
+ 'boolean',
+ 'last_triggered_at' => 'datetime',
+ ];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function feeds(): BelongsToMany
+ {
+ return $this->belongsToMany(Feed::class);
+ }
+
+ public function scopeEnabled(Builder $query): Builder
+ {
+ return $query->where('enabled', true);
+ }
+
+ public function scopeForFeed(Builder $query, int $feedId): Builder
+ {
+ return $query->where(function (Builder $q) use ($feedId) {
+ $q->where('scope', self::SCOPE_ALL)
+ ->orWhere(function (Builder $q2) use ($feedId) {
+ $q2->where('scope', self::SCOPE_FEEDS)
+ ->whereHas('feeds', fn (Builder $f) => $f->where('feeds.id', $feedId));
+ });
+ });
+ }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
index 2bef9f0..234a74e 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -23,6 +23,11 @@ class User extends Authenticatable
return $this->hasMany(Feed::class);
}
+ public function notifiers(): HasMany
+ {
+ return $this->hasMany(Notifier::class);
+ }
+
/**
* Get the attributes that should be cast.
*
diff --git a/app/Policies/NotifierPolicy.php b/app/Policies/NotifierPolicy.php
new file mode 100644
index 0000000..2dfa3fa
--- /dev/null
+++ b/app/Policies/NotifierPolicy.php
@@ -0,0 +1,19 @@
+id === $notifier->user_id;
+ }
+
+ public function delete(User $user, Notifier $notifier): bool
+ {
+ return $user->id === $notifier->user_id;
+ }
+}
diff --git a/app/Services/FeedFetcherService.php b/app/Services/FeedFetcherService.php
index 983aacc..a3aecf8 100644
--- a/app/Services/FeedFetcherService.php
+++ b/app/Services/FeedFetcherService.php
@@ -13,6 +13,10 @@ use Throwable;
class FeedFetcherService
{
+ public function __construct(private readonly NotifierDispatchService $notifier)
+ {
+ }
+
/**
* Fetch, parse and store new articles for a single feed.
*
@@ -66,6 +70,8 @@ class FeedFetcherService
$article->feed()->associate($feed);
$article->save();
+ $this->notifier->notifyNewArticle($article);
+
$newCount++;
}
diff --git a/app/Services/NotifierDispatchService.php b/app/Services/NotifierDispatchService.php
new file mode 100644
index 0000000..59191ab
--- /dev/null
+++ b/app/Services/NotifierDispatchService.php
@@ -0,0 +1,116 @@
+feed;
+
+ $notifiers = Notifier::query()
+ ->where('user_id', $feed->user_id)
+ ->enabled()
+ ->forFeed($feed->id)
+ ->get();
+
+ foreach ($notifiers as $notifier) {
+ $this->send($notifier, $article);
+ }
+ }
+
+ public function sendTest(Notifier $notifier): bool
+ {
+ $payload = $notifier->type === Notifier::TYPE_DISCORD
+ ? $this->discordTestPayload()
+ : $this->webhookTestPayload();
+
+ return $this->deliver($notifier, $payload);
+ }
+
+ private function send(Notifier $notifier, Article $article): void
+ {
+ $payload = $notifier->type === Notifier::TYPE_DISCORD
+ ? $this->discordPayload($notifier, $article)
+ : $this->webhookPayload($notifier, $article);
+
+ $this->deliver($notifier, $payload);
+ }
+
+ private function deliver(Notifier $notifier, array $payload): bool
+ {
+ try {
+ $response = Http::timeout(6)->post($notifier->url, $payload);
+
+ if ($response->failed()) {
+ $notifier->update([
+ 'last_error' => Str::limit("HTTP {$response->status()}", 250, ''),
+ ]);
+
+ return false;
+ }
+
+ $notifier->update(['last_triggered_at' => now(), 'last_error' => null]);
+
+ return true;
+ } catch (Throwable $e) {
+ Log::warning('Notifier delivery failed', [
+ 'notifier_id' => $notifier->id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ $notifier->update(['last_error' => Str::limit($e->getMessage(), 250, '')]);
+
+ return false;
+ }
+ }
+
+ private function discordPayload(Notifier $notifier, Article $article): array
+ {
+ return [
+ 'embeds' => [[
+ 'title' => Str::limit($article->title, 250),
+ 'url' => $article->link,
+ 'description' => $article->excerpt ? Str::limit($article->excerpt, 300) : null,
+ 'color' => 15768330, // amber accent
+ 'footer' => ['text' => $article->feed->displayTitle()],
+ 'timestamp' => $article->published_at?->toIso8601String() ?? now()->toIso8601String(),
+ ]],
+ ];
+ }
+
+ private function webhookPayload(Notifier $notifier, Article $article): array
+ {
+ return [
+ 'event' => 'article.created',
+ 'feed' => $article->feed->displayTitle(),
+ 'article' => [
+ 'title' => $article->title,
+ 'url' => $article->link,
+ 'excerpt' => $article->excerpt,
+ 'published_at' => $article->published_at?->toIso8601String(),
+ ],
+ ];
+ }
+
+ private function discordTestPayload(): array
+ {
+ return ['content' => '🔔 BloomFeed test notification — your Discord webhook is correctly configured.'];
+ }
+
+ private function webhookTestPayload(): array
+ {
+ return ['event' => 'test', 'message' => 'BloomFeed test notification — your webhook is correctly configured.'];
+ }
+}
diff --git a/database/migrations/2026_07_04_092809_create_notifiers_table.php b/database/migrations/2026_07_04_092809_create_notifiers_table.php
new file mode 100644
index 0000000..fbe9f8a
--- /dev/null
+++ b/database/migrations/2026_07_04_092809_create_notifiers_table.php
@@ -0,0 +1,37 @@
+id();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->string('type'); // 'discord' | 'webhook'
+ $table->string('name');
+ $table->string('url', 2048);
+ $table->string('scope')->default('all'); // 'all' | 'feeds'
+ $table->boolean('enabled')->default(true);
+ $table->timestamp('last_triggered_at')->nullable();
+ $table->string('last_error')->nullable();
+ $table->timestamps();
+
+ $table->index(['user_id', 'enabled']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('notifiers');
+ }
+};
diff --git a/database/migrations/2026_07_04_092810_create_feed_notifier_table.php b/database/migrations/2026_07_04_092810_create_feed_notifier_table.php
new file mode 100644
index 0000000..d615281
--- /dev/null
+++ b/database/migrations/2026_07_04_092810_create_feed_notifier_table.php
@@ -0,0 +1,30 @@
+foreignId('notifier_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('feed_id')->constrained()->cascadeOnDelete();
+ $table->primary(['notifier_id', 'feed_id']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('feed_notifier');
+ }
+};
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index eb940d6..4ee122f 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -1,18 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
-echo "==> Attente de la base de données (${DB_HOST:-db}:${DB_PORT:-3306})"
+echo "==> Waiting for the database (${DB_HOST:-db}:${DB_PORT:-3306})"
for i in $(seq 1 30); do
if php -r "exit(@fsockopen(getenv('DB_HOST') ?: 'db', (int) (getenv('DB_PORT') ?: 3306)) ? 0 : 1);"; then
- echo "==> Base de données disponible"
+ echo "==> Database available"
break
fi
- echo "==> DB indisponible, nouvelle tentative dans 2s ($i/30)"
+ echo "==> Database not available yet, retrying in 2s ($i/30)"
sleep 2
done
-echo "==> Exécution des migrations"
+echo "==> Running migrations"
php artisan migrate --force
-echo "==> Démarrage d'Apache"
+echo "==> Starting Apache"
exec apache2-foreground
diff --git a/docs/screenshots/notifiers-light.png b/docs/screenshots/notifiers-light.png
new file mode 100644
index 0000000..e2bac6a
Binary files /dev/null and b/docs/screenshots/notifiers-light.png differ
diff --git a/lang/de/app.php b/lang/de/app.php
index 1cbb324..a0bab3a 100644
--- a/lang/de/app.php
+++ b/lang/de/app.php
@@ -143,4 +143,39 @@ return [
'source_removed' => 'Quelle entfernt.',
'source_invalid' => 'Diese Quelle konnte nicht gelesen werden (gültige bloomflux.json-Datei erwartet).',
'no_sources' => 'Noch keine externen Quellen.',
+
+ // Benachrichtigungen
+ 'notifiers' => 'Benachrichtigungen',
+ 'notifiers_lede' => 'Erhalte eine Discord-Benachrichtigung oder einen generischen Webhook-Aufruf, sobald ein neuer Artikel in deinen Feeds erscheint. Jeder Nutzer verwaltet seine eigenen Benachrichtigungen.',
+ 'new_notifier' => 'Neue Benachrichtigung',
+ 'notifier_type' => 'Typ',
+ 'generic_webhook' => 'Generischer Webhook',
+ 'notifier_name' => 'Name',
+ 'notifier_name_placeholder' => 'z. B. Discord #news',
+ 'notifier_url' => 'Webhook-URL',
+ 'notifier_scope' => 'Umfang',
+ 'scope_all' => 'Alle meine Feeds',
+ 'scope_feeds' => 'Bestimmte Feeds',
+ 'scope_feeds_pick' => 'Feeds auswählen',
+ 'scope_feeds_list' => 'Feeds: :feeds',
+ 'no_feeds_selected' => 'kein Feed ausgewählt',
+ 'add_notifier' => 'Benachrichtigung hinzufügen',
+ 'your_notifiers' => 'Deine Benachrichtigungen',
+ 'no_notifiers_yet' => 'Du hast noch keine Benachrichtigung eingerichtet.',
+ 'disabled' => 'deaktiviert',
+ 'send_test' => 'Testen',
+ 'enable' => 'Aktivieren',
+ 'disable' => 'Deaktivieren',
+ 'confirm_delete_notifier' => 'Diese Benachrichtigung löschen?',
+ 'edit' => 'Bearbeiten',
+ 'save' => 'Speichern',
+ 'last_triggered' => 'ausgelöst :time',
+ 'notifier_created' => 'Benachrichtigung erstellt.',
+ 'notifier_updated' => 'Benachrichtigung aktualisiert.',
+ 'notifier_deleted' => 'Benachrichtigung gelöscht.',
+ 'notifier_test_ok' => 'Testbenachrichtigung erfolgreich gesendet.',
+ 'notifier_test_failed' => 'Testbenachrichtigung konnte nicht gesendet werden — URL prüfen.',
+
+ 'reset_title' => 'Zurücksetzen',
+ 'reset_text' => 'Das Skript reset.sh setzt die Instanz auf einen leeren Zustand zurück (Konten, Feeds, Artikel, Benachrichtigungen), ohne Quellcode, .env oder Docker-Images anzutasten — nützlich, um mit einer sauberen Instanz neu zu starten.',
];
diff --git a/lang/en/app.php b/lang/en/app.php
index fafdec0..0dcf721 100644
--- a/lang/en/app.php
+++ b/lang/en/app.php
@@ -143,4 +143,39 @@ return [
'source_removed' => 'Source removed.',
'source_invalid' => 'Could not read this source (a valid bloomflux.json file is expected).',
'no_sources' => 'No external sources yet.',
+
+ // Notifiers
+ 'notifiers' => 'Notifiers',
+ 'notifiers_lede' => 'Get a Discord alert or a generic webhook call whenever a new article arrives on your feeds. Each user manages their own notifiers.',
+ 'new_notifier' => 'New notifier',
+ 'notifier_type' => 'Type',
+ 'generic_webhook' => 'Generic webhook',
+ 'notifier_name' => 'Name',
+ 'notifier_name_placeholder' => 'e.g. Discord #news',
+ 'notifier_url' => 'Webhook URL',
+ 'notifier_scope' => 'Scope',
+ 'scope_all' => 'All my feeds',
+ 'scope_feeds' => 'Specific feeds',
+ 'scope_feeds_pick' => 'Choose feeds',
+ 'scope_feeds_list' => 'Feeds: :feeds',
+ 'no_feeds_selected' => 'no feed selected',
+ 'add_notifier' => 'Add notifier',
+ 'your_notifiers' => 'Your notifiers',
+ 'no_notifiers_yet' => "You haven't configured any notifier yet.",
+ 'disabled' => 'disabled',
+ 'send_test' => 'Test',
+ 'enable' => 'Enable',
+ 'disable' => 'Disable',
+ 'confirm_delete_notifier' => 'Delete this notifier?',
+ 'edit' => 'Edit',
+ 'save' => 'Save',
+ 'last_triggered' => 'triggered :time',
+ 'notifier_created' => 'Notifier created.',
+ 'notifier_updated' => 'Notifier updated.',
+ 'notifier_deleted' => 'Notifier deleted.',
+ 'notifier_test_ok' => 'Test notification sent successfully.',
+ 'notifier_test_failed' => 'Failed to send the test notification — check the URL.',
+
+ 'reset_title' => 'Reset',
+ 'reset_text' => 'The reset.sh script wipes the instance back to a blank state (accounts, feeds, articles, notifiers) without touching the source code, .env or Docker images — useful to start over on a clean instance.',
];
diff --git a/lang/es/app.php b/lang/es/app.php
index 2cfb782..7abd201 100644
--- a/lang/es/app.php
+++ b/lang/es/app.php
@@ -143,4 +143,39 @@ return [
'source_removed' => 'Fuente eliminada.',
'source_invalid' => 'No se pudo leer esta fuente (se espera un archivo bloomflux.json válido).',
'no_sources' => 'Ninguna fuente externa por ahora.',
+
+ // Notificadores
+ 'notifiers' => 'Notificadores',
+ 'notifiers_lede' => 'Recibe una alerta de Discord o una llamada webhook genérica cuando llegue un nuevo artículo a tus fuentes. Cada usuario gestiona sus propios notificadores.',
+ 'new_notifier' => 'Nuevo notificador',
+ 'notifier_type' => 'Tipo',
+ 'generic_webhook' => 'Webhook genérico',
+ 'notifier_name' => 'Nombre',
+ 'notifier_name_placeholder' => 'Ej: Discord #noticias',
+ 'notifier_url' => 'URL del webhook',
+ 'notifier_scope' => 'Alcance',
+ 'scope_all' => 'Todas mis fuentes',
+ 'scope_feeds' => 'Fuentes específicas',
+ 'scope_feeds_pick' => 'Elegir fuentes',
+ 'scope_feeds_list' => 'Fuentes: :feeds',
+ 'no_feeds_selected' => 'ninguna fuente seleccionada',
+ 'add_notifier' => 'Añadir notificador',
+ 'your_notifiers' => 'Tus notificadores',
+ 'no_notifiers_yet' => 'Todavía no has configurado ningún notificador.',
+ 'disabled' => 'desactivado',
+ 'send_test' => 'Probar',
+ 'enable' => 'Activar',
+ 'disable' => 'Desactivar',
+ 'confirm_delete_notifier' => '¿Eliminar este notificador?',
+ 'edit' => 'Editar',
+ 'save' => 'Guardar',
+ 'last_triggered' => 'activado :time',
+ 'notifier_created' => 'Notificador creado.',
+ 'notifier_updated' => 'Notificador actualizado.',
+ 'notifier_deleted' => 'Notificador eliminado.',
+ 'notifier_test_ok' => 'Notificación de prueba enviada con éxito.',
+ 'notifier_test_failed' => 'No se pudo enviar la notificación de prueba — comprueba la URL.',
+
+ 'reset_title' => 'Reiniciar',
+ 'reset_text' => 'El script reset.sh restablece la instancia a un estado en blanco (cuentas, fuentes, artículos, notificadores) sin tocar el código fuente, el .env ni las imágenes Docker — útil para empezar de cero.',
];
diff --git a/lang/fr/app.php b/lang/fr/app.php
index 870aef7..843e46e 100644
--- a/lang/fr/app.php
+++ b/lang/fr/app.php
@@ -143,4 +143,39 @@ return [
'source_removed' => 'Source retirée.',
'source_invalid' => 'Impossible de lire cette source (fichier bloomflux.json valide attendu).',
'no_sources' => 'Aucune source externe pour le moment.',
+
+ // Notifieurs
+ 'notifiers' => 'Notifieurs',
+ 'notifiers_lede' => 'Recevez une alerte Discord ou un webhook générique quand un nouvel article arrive sur vos flux. Chaque utilisateur gère ses propres notifieurs.',
+ 'new_notifier' => 'Nouveau notifieur',
+ 'notifier_type' => 'Type',
+ 'generic_webhook' => 'Webhook générique',
+ 'notifier_name' => 'Nom',
+ 'notifier_name_placeholder' => 'Ex: Discord #actus',
+ 'notifier_url' => "URL du webhook",
+ 'notifier_scope' => 'Portée',
+ 'scope_all' => 'Tous mes flux',
+ 'scope_feeds' => 'Flux spécifiques',
+ 'scope_feeds_pick' => 'Choisir les flux',
+ 'scope_feeds_list' => 'Flux : :feeds',
+ 'no_feeds_selected' => 'aucun flux sélectionné',
+ 'add_notifier' => 'Ajouter le notifieur',
+ 'your_notifiers' => 'Vos notifieurs',
+ 'no_notifiers_yet' => "Vous n'avez pas encore configuré de notifieur.",
+ 'disabled' => 'désactivé',
+ 'send_test' => 'Tester',
+ 'enable' => 'Activer',
+ 'disable' => 'Désactiver',
+ 'confirm_delete_notifier' => 'Supprimer ce notifieur ?',
+ 'edit' => 'Modifier',
+ 'save' => 'Enregistrer',
+ 'last_triggered' => 'déclenché :time',
+ 'notifier_created' => 'Notifieur créé.',
+ 'notifier_updated' => 'Notifieur mis à jour.',
+ 'notifier_deleted' => 'Notifieur supprimé.',
+ 'notifier_test_ok' => 'Notification de test envoyée avec succès.',
+ 'notifier_test_failed' => "Échec de l'envoi de la notification de test — vérifiez l'URL.",
+
+ 'reset_title' => 'Réinitialiser',
+ 'reset_text' => "Le script reset.sh remet l'instance à zéro (comptes, flux, articles, notifieurs) sans toucher au code, au .env ni aux images Docker — utile pour repartir sur une instance vierge.",
];
diff --git a/public/css/app.css b/public/css/app.css
index 6efbc14..63d0336 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -1195,6 +1195,40 @@ h1, h2, h3 {
z-index: 30;
}
+/* ---------- Notifiers ---------- */
+
+.notifier-form .form-group {
+ margin-bottom: var(--space-3);
+}
+
+.notifier-feed-picker {
+ background: var(--surface-alt);
+ border-radius: var(--radius-sm);
+ padding: var(--space-3);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-height: 180px;
+ overflow-y: auto;
+}
+
+.notifier-feed-picker[hidden] {
+ display: none;
+}
+
+.notifier-feed-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: var(--font-sm);
+ font-weight: 400;
+ cursor: pointer;
+}
+
+.notifier-card summary::-webkit-details-marker {
+ color: var(--text-muted);
+}
+
/* ---------- Responsive ---------- */
@media (max-width: 720px) {
diff --git a/public/js/app.js b/public/js/app.js
index 8b8d85b..51ea710 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -83,4 +83,16 @@
applyFilters();
});
});
+
+ // Notifiers: show/hide the per-feed picker depending on the chosen scope
+ document.addEventListener('change', function (event) {
+ var select = event.target.closest('[data-scope-select]');
+ if (!select) {
+ return;
+ }
+ var picker = select.closest('form').querySelector('[data-scope-feeds]');
+ if (picker) {
+ picker.hidden = select.value !== 'feeds';
+ }
+ });
})();
diff --git a/reset.sh b/reset.sh
new file mode 100755
index 0000000..be5eab6
--- /dev/null
+++ b/reset.sh
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Resets a BloomFeed instance to a fresh, empty state: wipes the database and
+# application storage, then restarts the stack so the first user to register
+# becomes the new owner. Source code, .env and Docker images are untouched —
+# use uninstall.sh instead if you want to remove those too.
+
+APP_DIR="/opt/bloomfeed"
+PROJECT_NAME="bloomfeed"
+
+ASSUME_YES=false
+
+for arg in "$@"; do
+ case "$arg" in
+ --yes|-y) ASSUME_YES=true ;;
+ --help|-h)
+ echo "Usage: bash reset.sh [--yes]"
+ echo " --yes Skip the interactive confirmation"
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $arg (use --help)"
+ exit 1
+ ;;
+ esac
+done
+
+if [[ ! -d "${APP_DIR}" ]]; then
+ echo "ERROR: ${APP_DIR} not found — nothing to reset."
+ exit 1
+fi
+
+cd "${APP_DIR}"
+
+echo "WARNING: this will PERMANENTLY delete all data on this instance:"
+echo " - the MariaDB database (all accounts, feeds, articles, notifiers)"
+echo " - the application storage files"
+echo "The source code, .env and Docker images are kept."
+
+if [[ "${ASSUME_YES}" != true ]]; then
+ read -r -p "Type RESET to confirm: " CONFIRM
+ if [[ "${CONFIRM}" != "RESET" ]]; then
+ echo "Cancelled. Nothing was changed."
+ exit 0
+ fi
+fi
+
+echo "==> Stopping the stack"
+sudo docker compose down --remove-orphans
+
+echo "==> Removing data volumes"
+sudo docker volume rm \
+ "${PROJECT_NAME}_db_data" \
+ "${PROJECT_NAME}_app_storage" \
+ "${PROJECT_NAME}_scheduler_storage" \
+ 2>/dev/null || true
+
+echo "==> Starting a fresh stack"
+sudo docker compose up -d
+
+echo "==> Waiting for the database to be ready"
+for i in $(seq 1 30); do
+ if sudo docker compose exec -T db healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; then
+ echo "==> Database ready"
+ break
+ fi
+ echo "==> Database not ready yet, retrying ($i/30)"
+ sleep 3
+done
+
+echo "==> Waiting for the entrypoint migrations to finish"
+for i in $(seq 1 30); do
+ if curl -sf -o /dev/null "http://localhost:7081/up"; then
+ echo "==> Application up"
+ break
+ fi
+ echo "==> Application not ready yet, retrying ($i/30)"
+ sleep 3
+done
+
+echo
+echo "==> Reset finished. Visit the instance and create the new owner account."
diff --git a/resources/views/notifiers.blade.php b/resources/views/notifiers.blade.php
new file mode 100644
index 0000000..1ad0cf4
--- /dev/null
+++ b/resources/views/notifiers.blade.php
@@ -0,0 +1,142 @@
+@extends('layouts.app', ['title' => __('app.notifiers').' — BloomFeed'])
+
+@section('content')
+
+ @if (session('status'))
+
{{ session('status') }}
+ @endif
+
+
{{ __('app.notifiers') }}
+
{{ __('app.notifiers_lede') }}
+
+
+
{{ __('app.new_notifier') }}
+
+ @if ($errors->any())
+
{{ $errors->first() }}
+ @endif
+
+
+
+
+
{{ __('app.your_notifiers') }}
+
+ @forelse ($notifiers as $notifier)
+
+
+
+ {{ $notifier->type === 'discord' ? 'Discord' : __('app.generic_webhook') }}
+ {{ $notifier->name }}
+ @unless ($notifier->enabled)
+ ({{ __('app.disabled') }})
+ @endunless
+
+
+
+
+
+
+
+
+
+ {{ $notifier->scope === 'all' ? __('app.scope_all') : __('app.scope_feeds_list', ['feeds' => $notifier->feeds->map->displayTitle()->join(', ') ?: __('app.no_feeds_selected')]) }}
+ @if ($notifier->last_triggered_at)
+ · {{ __('app.last_triggered', ['time' => $notifier->last_triggered_at->diffForHumans()]) }}
+ @endif
+ @if ($notifier->last_error)
+ · {{ $notifier->last_error }}
+ @endif
+
+
+
+ {{ __('app.edit') }}
+
+
+
+ @empty
+
{{ __('app.no_notifiers_yet') }}
+ @endforelse
+
+@endsection
diff --git a/resources/views/partials/navbar.blade.php b/resources/views/partials/navbar.blade.php
index 102e7ce..74fdb35 100644
--- a/resources/views/partials/navbar.blade.php
+++ b/resources/views/partials/navbar.blade.php
@@ -7,6 +7,7 @@
{{ __('app.articles') }}
{{ __('app.catalog') }}
{{ __('app.my_feeds') }}
+ {{ __('app.notifiers') }}
@if (auth()->user()?->is_owner)
{{ __('app.settings') }}
@endif
diff --git a/resources/views/self-hosting.blade.php b/resources/views/self-hosting.blade.php
index 1a8c314..72f945e 100644
--- a/resources/views/self-hosting.blade.php
+++ b/resources/views/self-hosting.blade.php
@@ -47,6 +47,12 @@ cd bloomfeed
bash scriptsite.sh
+
+
{{ __('app.reset_title') }}
+
{{ __('app.reset_text') }}
+
bash reset.sh
+
+
{{ __('app.uninstall_title') }}
{{ __('app.uninstall_text') }}
diff --git a/routes/web.php b/routes/web.php
index a63c87b..59eab71 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -5,6 +5,7 @@ use App\Http\Controllers\AuthController;
use App\Http\Controllers\CatalogController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\FeedController;
+use App\Http\Controllers\NotifierController;
use App\Http\Controllers\PageController;
use App\Http\Controllers\SettingsController;
use Illuminate\Support\Facades\Route;
@@ -33,6 +34,13 @@ Route::middleware('auth')->group(function () {
Route::post('/settings/sources', [SettingsController::class, 'addCatalogSource'])->name('settings.addSource');
Route::delete('/settings/sources', [SettingsController::class, 'removeCatalogSource'])->name('settings.removeSource');
+ Route::get('/notifiers', [NotifierController::class, 'index'])->name('notifiers');
+ Route::post('/notifiers', [NotifierController::class, 'store'])->name('notifiers.store');
+ Route::put('/notifiers/{notifier}', [NotifierController::class, 'update'])->name('notifiers.update');
+ Route::post('/notifiers/{notifier}/toggle', [NotifierController::class, 'toggle'])->name('notifiers.toggle');
+ Route::post('/notifiers/{notifier}/test', [NotifierController::class, 'test'])->name('notifiers.test');
+ Route::delete('/notifiers/{notifier}', [NotifierController::class, 'destroy'])->name('notifiers.destroy');
+
Route::get('/feeds', [FeedController::class, 'index'])->name('feeds.index');
Route::post('/feeds', [FeedController::class, 'store'])->name('feeds.store');
Route::delete('/feeds/{feed}', [FeedController::class, 'destroy'])->name('feeds.destroy');