Compare commits

..

6 commits

37 changed files with 675 additions and 400 deletions

View file

@ -23,14 +23,14 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=sqlite DB_CONNECTION=mysql
# DB_HOST=127.0.0.1 DB_HOST=db
# DB_PORT=3306 DB_PORT=3306
# DB_DATABASE=laravel DB_DATABASE=ffr_dev
# DB_USERNAME=root DB_USERNAME=ffr
# DB_PASSWORD= DB_PASSWORD=ffr
SESSION_DRIVER=database SESSION_DRIVER=redis
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
@ -38,15 +38,15 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=redis
CACHE_STORE=database CACHE_STORE=redis
# CACHE_PREFIX= # CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1 REDIS_HOST=redis
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379

View file

@ -0,0 +1,41 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ['v*']
jobs:
build:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up Docker Buildx
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
- name: Login to Forgejo Registry
uses: https://data.forgejo.org/docker/login-action@v3
with:
registry: forge.lvl0.xyz
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Determine tags
id: meta
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
echo "tags=forge.lvl0.xyz/lvl0/fedi-feed-router:${TAG},forge.lvl0.xyz/lvl0/fedi-feed-router:latest" >> $GITHUB_OUTPUT
else
echo "tags=forge.lvl0.xyz/lvl0/fedi-feed-router:latest" >> $GITHUB_OUTPUT
fi
- name: Build and push
uses: https://data.forgejo.org/docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}

1
.gitignore vendored
View file

@ -4,6 +4,7 @@
/public/build /public/build
/public/hot /public/hot
/public/storage /public/storage
/public/vendor
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/vendor /vendor

View file

@ -13,14 +13,14 @@ ## Features
## Self-hosting ## Self-hosting
The production image is available at `codeberg.org/lvl0/ffr:latest`. The production image is available at `forge.lvl0.xyz/lvl0/fedi-feed-router:latest`.
### docker-compose.yml ### docker-compose.yml
```yaml ```yaml
services: services:
app: app:
image: codeberg.org/lvl0/ffr:latest image: forge.lvl0.xyz/lvl0/fedi-feed-router:latest
container_name: ffr_app container_name: ffr_app
restart: always restart: always
ports: ports:
@ -86,7 +86,7 @@ ## Development
### NixOS / Nix ### NixOS / Nix
```bash ```bash
git clone https://codeberg.org/lvl0/ffr.git git clone https://forge.lvl0.xyz/lvl0/fedi-feed-router.git
cd ffr cd ffr
nix-shell nix-shell
``` ```
@ -104,7 +104,6 @@ #### Available Commands
| `dev-logs-db` | Follow database logs | | `dev-logs-db` | Follow database logs |
| `dev-shell` | Enter app container | | `dev-shell` | Enter app container |
| `dev-artisan <cmd>` | Run artisan commands | | `dev-artisan <cmd>` | Run artisan commands |
| `prod-build [tag]` | Build and push prod image (default: latest) |
#### Services #### Services
@ -125,4 +124,4 @@ ## License
## Support ## Support
For issues and questions, please use [Codeberg Issues](https://codeberg.org/lvl0/ffr/issues). For issues and questions, please use [Issues](https://forge.lvl0.xyz/lvl0/fedi-feed-router/issues).

View file

@ -34,15 +34,15 @@ public function update(Request $request): JsonResponse
try { try {
$validated = $request->validate([ $validated = $request->validate([
'article_processing_enabled' => 'boolean', 'article_processing_enabled' => 'boolean',
'enable_publishing_approvals' => 'boolean', 'publishing_approvals_enabled' => 'boolean',
]); ]);
if (isset($validated['article_processing_enabled'])) { if (isset($validated['article_processing_enabled'])) {
Setting::setArticleProcessingEnabled($validated['article_processing_enabled']); Setting::setArticleProcessingEnabled($validated['article_processing_enabled']);
} }
if (isset($validated['enable_publishing_approvals'])) { if (isset($validated['publishing_approvals_enabled'])) {
Setting::setPublishingApprovalsEnabled($validated['enable_publishing_approvals']); Setting::setPublishingApprovalsEnabled($validated['publishing_approvals_enabled']);
} }
$updatedSettings = [ $updatedSettings = [
@ -60,4 +60,4 @@ public function update(Request $request): JsonResponse
return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500);
} }
} }
} }

View file

@ -21,6 +21,7 @@ public function toArray(Request $request): array
'is_valid' => $this->is_valid, 'is_valid' => $this->is_valid,
'is_duplicate' => $this->is_duplicate, 'is_duplicate' => $this->is_duplicate,
'approval_status' => $this->approval_status, 'approval_status' => $this->approval_status,
'publish_status' => $this->publish_status,
'approved_at' => $this->approved_at?->toISOString(), 'approved_at' => $this->approved_at?->toISOString(),
'approved_by' => $this->approved_by, 'approved_by' => $this->approved_by,
'fetched_at' => $this->fetched_at?->toISOString(), 'fetched_at' => $this->fetched_at?->toISOString(),

View file

@ -0,0 +1,64 @@
<?php
namespace App\Listeners;
use App\Events\ArticleApproved;
use App\Services\Article\ArticleFetcher;
use App\Services\Publishing\ArticlePublishingService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
class PublishApprovedArticleListener implements ShouldQueue
{
public string $queue = 'publishing';
public function __construct(
private ArticleFetcher $articleFetcher,
private ArticlePublishingService $publishingService
) {}
public function handle(ArticleApproved $event): void
{
$article = $event->article->fresh();
// Skip if already published
if ($article->articlePublication()->exists()) {
return;
}
// Skip if not approved (safety check)
if (! $article->isApproved()) {
return;
}
$article->update(['publish_status' => 'publishing']);
try {
$extractedData = $this->articleFetcher->fetchArticleData($article);
$publications = $this->publishingService->publishToRoutedChannels($article, $extractedData);
if ($publications->isNotEmpty()) {
$article->update(['publish_status' => 'published']);
logger()->info('Published approved article', [
'article_id' => $article->id,
'title' => $article->title,
]);
} else {
$article->update(['publish_status' => 'error']);
logger()->warning('No publications created for approved article', [
'article_id' => $article->id,
'title' => $article->title,
]);
}
} catch (Exception $e) {
$article->update(['publish_status' => 'error']);
logger()->error('Failed to publish approved article', [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
}
}
}

View file

@ -3,16 +3,20 @@
namespace App\Listeners; namespace App\Listeners;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use App\Events\ArticleApproved;
use App\Models\Setting; use App\Models\Setting;
use App\Services\Article\ValidationService; use App\Services\Article\ValidationService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
class ValidateArticleListener implements ShouldQueue class ValidateArticleListener implements ShouldQueue
{ {
public string $queue = 'default'; public string $queue = 'default';
public function handle(NewArticleFetched $event, ValidationService $validationService): void public function __construct(
private ValidationService $validationService
) {}
public function handle(NewArticleFetched $event): void
{ {
$article = $event->article; $article = $event->article;
@ -20,12 +24,25 @@ public function handle(NewArticleFetched $event, ValidationService $validationSe
return; return;
} }
// Only validate articles that are still pending
if (! $article->isPending()) {
return;
}
// Skip if already has publication (prevents duplicate processing) // Skip if already has publication (prevents duplicate processing)
if ($article->articlePublication()->exists()) { if ($article->articlePublication()->exists()) {
return; return;
} }
$article = $validationService->validate($article); try {
$article = $this->validationService->validate($article);
} catch (Exception $e) {
logger()->error('Article validation failed', [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
return;
}
if ($article->isValid()) { if ($article->isValid()) {
// Double-check publication doesn't exist (race condition protection) // Double-check publication doesn't exist (race condition protection)
@ -33,16 +50,10 @@ public function handle(NewArticleFetched $event, ValidationService $validationSe
return; return;
} }
// Check if approval system is enabled // If approvals are enabled, article waits for manual approval.
if (Setting::isPublishingApprovalsEnabled()) { // If approvals are disabled, auto-approve and publish.
// If approvals are enabled, only proceed if article is approved if (! Setting::isPublishingApprovalsEnabled()) {
if ($article->isApproved()) { $article->approve();
event(new ArticleApproved($article));
}
// If not approved, article will wait for manual approval
} else {
// If approvals are disabled, proceed with publishing
event(new ArticleApproved($article));
} }
} }
} }

View file

@ -3,6 +3,7 @@
namespace App\Livewire; namespace App\Livewire;
use App\Jobs\ArticleDiscoveryJob; use App\Jobs\ArticleDiscoveryJob;
use App\Jobs\SyncChannelPostsJob;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Language; use App\Models\Language;
use App\Models\PlatformAccount; use App\Models\PlatformAccount;
@ -17,7 +18,7 @@
class Onboarding extends Component class Onboarding extends Component
{ {
// Step tracking (1-6: welcome, platform, feed, channel, route, complete) // Step tracking (1-6: welcome, platform, channel, feed, route, complete)
public int $step = 1; public int $step = 1;
// Platform form // Platform form
@ -142,7 +143,14 @@ public function createPlatformAccount(): void
$fullInstanceUrl = 'https://' . $this->instanceUrl; $fullInstanceUrl = 'https://' . $this->instanceUrl;
try { try {
// Create or get platform instance // Authenticate with Lemmy API first (before creating any records)
$authResponse = $this->lemmyAuthService->authenticate(
$fullInstanceUrl,
$this->username,
$this->password
);
// Only create platform instance after successful authentication
$platformInstance = PlatformInstance::firstOrCreate([ $platformInstance = PlatformInstance::firstOrCreate([
'url' => $fullInstanceUrl, 'url' => $fullInstanceUrl,
'platform' => 'lemmy', 'platform' => 'lemmy',
@ -151,13 +159,6 @@ public function createPlatformAccount(): void
'is_active' => true, 'is_active' => true,
]); ]);
// Authenticate with Lemmy API
$authResponse = $this->lemmyAuthService->authenticate(
$fullInstanceUrl,
$this->username,
$this->password
);
// Create platform account // Create platform account
$platformAccount = PlatformAccount::create([ $platformAccount = PlatformAccount::create([
'platform' => 'lemmy', 'platform' => 'lemmy',
@ -286,6 +287,9 @@ public function createChannel(): void
'updated_at' => now(), 'updated_at' => now(),
]); ]);
// Sync existing posts from this channel for duplicate detection
SyncChannelPostsJob::dispatch($channel);
$this->nextStep(); $this->nextStep();
} catch (\Exception $e) { } catch (\Exception $e) {
$this->formErrors['general'] = 'Failed to create channel. Please try again.'; $this->formErrors['general'] = 'Failed to create channel. Please try again.';

View file

@ -40,6 +40,8 @@ class Article extends Model
'published_at', 'published_at',
'author', 'author',
'approval_status', 'approval_status',
'validated_at',
'publish_status',
]; ];
/** /**
@ -49,7 +51,9 @@ public function casts(): array
{ {
return [ return [
'approval_status' => 'string', 'approval_status' => 'string',
'publish_status' => 'string',
'published_at' => 'datetime', 'published_at' => 'datetime',
'validated_at' => 'datetime',
'created_at' => 'datetime', 'created_at' => 'datetime',
'updated_at' => 'datetime', 'updated_at' => 'datetime',
]; ];
@ -57,9 +61,8 @@ public function casts(): array
public function isValid(): bool public function isValid(): bool
{ {
// In the consolidated schema, we only have approval_status // Article is valid if it passed validation and wasn't rejected
// Consider 'approved' status as valid return $this->validated_at !== null && ! $this->isRejected();
return $this->approval_status === 'approved';
} }
public function isApproved(): bool public function isApproved(): bool
@ -130,10 +133,8 @@ public function feed(): BelongsTo
return $this->belongsTo(Feed::class); return $this->belongsTo(Feed::class);
} }
protected static function booted(): void public function dispatchFetchedEvent(): void
{ {
static::created(function ($article) { event(new NewArticleFetched($this));
event(new NewArticleFetched($article));
});
} }
} }

View file

@ -42,6 +42,25 @@ public static function urlExists(PlatformEnum $platform, string $channelId, stri
->exists(); ->exists();
} }
public static function duplicateExists(PlatformEnum $platform, string $channelId, ?string $url, ?string $title): bool
{
if (!$url && !$title) {
return false;
}
return self::where('platform', $platform)
->where('channel_id', $channelId)
->where(function ($query) use ($url, $title) {
if ($url) {
$query->orWhere('url', $url);
}
if ($title) {
$query->orWhere('title', $title);
}
})
->exists();
}
public static function storePost(PlatformEnum $platform, string $channelId, ?string $channelName, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self public static function storePost(PlatformEnum $platform, string $channelId, ?string $channelName, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self
{ {
return self::updateOrCreate( return self::updateOrCreate(

View file

@ -52,7 +52,7 @@ public static function setArticleProcessingEnabled(bool $enabled): void
public static function isPublishingApprovalsEnabled(): bool public static function isPublishingApprovalsEnabled(): bool
{ {
return static::getBool('enable_publishing_approvals', false); return static::getBool('enable_publishing_approvals', true);
} }
public static function setPublishingApprovalsEnabled(bool $enabled): void public static function setPublishingApprovalsEnabled(bool $enabled): void

View file

@ -73,10 +73,12 @@ public function login(string $username, string $password): ?string
if ($idx === 0 && in_array('http', $schemesToTry, true)) { if ($idx === 0 && in_array('http', $schemesToTry, true)) {
continue; continue;
} }
return null; // Connection failed - throw exception to distinguish from auth failure
throw new Exception('Connection failed: ' . $e->getMessage());
} }
} }
// If we get here without a token, it's an auth failure (not connection)
return null; return null;
} }

View file

@ -28,13 +28,30 @@ public function __construct(PlatformAccount $account)
*/ */
public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array
{ {
$token = resolve(LemmyAuthService::class)->getToken($this->account); $authService = resolve(LemmyAuthService::class);
$token = $authService->getToken($this->account);
// Use the language ID from extracted data (should be set during validation) try {
return $this->createPost($token, $extractedData, $channel, $article);
} catch (Exception $e) {
// If the cached token was stale, refresh and retry once
if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) {
$token = $authService->refreshToken($this->account);
return $this->createPost($token, $extractedData, $channel, $article);
}
throw $e;
}
}
/**
* @param array<string, mixed> $extractedData
* @return array<string, mixed>
*/
private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article): array
{
$languageId = $extractedData['language_id'] ?? null; $languageId = $extractedData['language_id'] ?? null;
// Resolve community name to numeric ID if needed $communityId = is_numeric($channel->channel_id)
$communityId = is_numeric($channel->channel_id)
? (int) $channel->channel_id ? (int) $channel->channel_id
: $this->api->getCommunityId($channel->channel_id, $token); : $this->api->getCommunityId($channel->channel_id, $token);

View file

@ -30,6 +30,10 @@ public function boot(): void
\App\Listeners\ValidateArticleListener::class, \App\Listeners\ValidateArticleListener::class,
); );
Event::listen(
\App\Events\ArticleApproved::class,
\App\Listeners\PublishApprovedArticleListener::class,
);
app()->make(ExceptionHandler::class) app()->make(ExceptionHandler::class)
->reportable(function (Throwable $e) { ->reportable(function (Throwable $e) {

View file

@ -103,27 +103,27 @@ public function fetchArticleData(Article $article): array
private function saveArticle(string $url, ?int $feedId = null): Article private function saveArticle(string $url, ?int $feedId = null): Article
{ {
$existingArticle = Article::where('url', $url)->first();
if ($existingArticle) {
return $existingArticle;
}
// Extract a basic title from URL as fallback
$fallbackTitle = $this->generateFallbackTitle($url); $fallbackTitle = $this->generateFallbackTitle($url);
try { try {
return Article::create([ $article = Article::firstOrCreate(
'url' => $url, ['url' => $url],
'feed_id' => $feedId, [
'title' => $fallbackTitle, 'feed_id' => $feedId,
]); 'title' => $fallbackTitle,
]
);
if ($article->wasRecentlyCreated) {
$article->dispatchFetchedEvent();
}
return $article;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logSaver->error("Failed to create article - title validation failed", null, [ $this->logSaver->error("Failed to create article", null, [
'url' => $url, 'url' => $url,
'feed_id' => $feedId, 'feed_id' => $feedId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'suggestion' => 'Check regex parsing patterns for title extraction'
]); ]);
throw $e; throw $e;
} }
@ -134,12 +134,12 @@ private function generateFallbackTitle(string $url): string
// Extract filename from URL as a basic fallback title // Extract filename from URL as a basic fallback title
$path = parse_url($url, PHP_URL_PATH); $path = parse_url($url, PHP_URL_PATH);
$filename = basename($path ?: $url); $filename = basename($path ?: $url);
// Remove file extension and convert to readable format // Remove file extension and convert to readable format
$title = preg_replace('/\.[^.]*$/', '', $filename); $title = preg_replace('/\.[^.]*$/', '', $filename);
$title = str_replace(['-', '_'], ' ', $title); $title = str_replace(['-', '_'], ' ', $title);
$title = ucwords($title); $title = ucwords($title);
return $title ?: 'Untitled Article'; return $title ?: 'Untitled Article';
} }
} }

View file

@ -15,7 +15,7 @@ public function validate(Article $article): Article
logger('Checking keywords for article: ' . $article->id); logger('Checking keywords for article: ' . $article->id);
$articleData = $this->articleFetcher->fetchArticleData($article); $articleData = $this->articleFetcher->fetchArticleData($article);
// Update article with fetched metadata (title, description) // Update article with fetched metadata (title, description)
$updateData = []; $updateData = [];
@ -24,23 +24,29 @@ public function validate(Article $article): Article
$updateData['description'] = $articleData['description'] ?? $article->description; $updateData['description'] = $articleData['description'] ?? $article->description;
$updateData['content'] = $articleData['full_article'] ?? null; $updateData['content'] = $articleData['full_article'] ?? null;
} }
if (!isset($articleData['full_article']) || empty($articleData['full_article'])) { if (!isset($articleData['full_article']) || empty($articleData['full_article'])) {
logger()->warning('Article data missing full_article content', [ logger()->warning('Article data missing full_article content', [
'article_id' => $article->id, 'article_id' => $article->id,
'url' => $article->url 'url' => $article->url
]); ]);
$updateData['approval_status'] = 'rejected'; $updateData['approval_status'] = 'rejected';
$article->update($updateData); $article->update($updateData);
return $article->refresh(); return $article->refresh();
} }
// Validate using extracted content (not stored)
$validationResult = $this->validateByKeywords($articleData['full_article']);
$updateData['approval_status'] = $validationResult ? 'approved' : 'pending';
// Validate content against keywords. If validation fails, reject.
// If validation passes, leave approval_status as-is (pending) —
// the listener decides whether to auto-approve based on settings.
$validationResult = $this->validateByKeywords($articleData['full_article']);
if (! $validationResult) {
$updateData['approval_status'] = 'rejected';
}
$updateData['validated_at'] = now();
$article->update($updateData); $article->update($updateData);
return $article->refresh(); return $article->refresh();
@ -53,12 +59,12 @@ private function validateByKeywords(string $full_article): bool
// Political parties and leaders // Political parties and leaders
'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo', 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo',
'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA', 'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA',
// Belgian locations and institutions // Belgian locations and institutions
'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels', 'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels',
'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi', 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi',
'parliament', 'government', 'minister', 'policy', 'law', 'legislation', 'parliament', 'government', 'minister', 'policy', 'law', 'legislation',
// Common Belgian news topics // Common Belgian news topics
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy',
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police' 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police'

View file

@ -14,6 +14,22 @@ class LemmyAuthService
* @throws PlatformAuthException * @throws PlatformAuthException
*/ */
public function getToken(PlatformAccount $account): string public function getToken(PlatformAccount $account): string
{
// Use cached token if available
$cachedToken = $account->settings['api_token'] ?? null;
if ($cachedToken) {
return $cachedToken;
}
return $this->refreshToken($account);
}
/**
* Clear cached token and re-authenticate.
*
* @throws PlatformAuthException
*/
public function refreshToken(PlatformAccount $account): string
{ {
if (! $account->username || ! $account->password || ! $account->instance_url) { if (! $account->username || ! $account->password || ! $account->instance_url) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username);
@ -26,6 +42,11 @@ public function getToken(PlatformAccount $account): string
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username);
} }
// Cache the token for future use
$settings = $account->settings ?? [];
$settings['api_token'] = $token;
$account->update(['settings' => $settings]);
return $token; return $token;
} }
@ -65,6 +86,10 @@ public function authenticate(string $instanceUrl, string $username, string $pass
if (str_contains($e->getMessage(), 'Rate limited by')) { if (str_contains($e->getMessage(), 'Rate limited by')) {
throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage()); throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage());
} }
// Check if it's a connection failure
if (str_contains($e->getMessage(), 'Connection failed')) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
}
// For other exceptions, throw a clean PlatformAuthException // For other exceptions, throw a clean PlatformAuthException
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed'); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
} }

View file

@ -7,6 +7,7 @@
use App\Models\Article; use App\Models\Article;
use App\Models\ArticlePublication; use App\Models\ArticlePublication;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\PlatformChannelPost;
use App\Models\Route; use App\Models\Route;
use App\Modules\Lemmy\Services\LemmyPublisher; use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
@ -78,7 +79,7 @@ private function routeMatchesArticle(Route $route, array $extractedData): bool
{ {
// Get active keywords for this route // Get active keywords for this route
$activeKeywords = $route->keywords->where('is_active', true); $activeKeywords = $route->keywords->where('is_active', true);
// If no keywords are defined for this route, the route matches any article // If no keywords are defined for this route, the route matches any article
if ($activeKeywords->isEmpty()) { if ($activeKeywords->isEmpty()) {
return true; return true;
@ -113,6 +114,23 @@ private function routeMatchesArticle(Route $route, array $extractedData): bool
private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication
{ {
try { try {
// Check if this URL or title was already posted to this channel
$title = $extractedData['title'] ?? $article->title;
if (PlatformChannelPost::duplicateExists(
$channel->platformInstance->platform,
(string) $channel->channel_id,
$article->url,
$title
)) {
$this->logSaver->info('Skipping duplicate: URL or title already posted to channel', $channel, [
'article_id' => $article->id,
'url' => $article->url,
'title' => $title,
]);
return null;
}
$publisher = $this->makePublisher($account); $publisher = $this->makePublisher($account);
$postData = $publisher->publishToChannel($article, $extractedData, $channel); $postData = $publisher->publishToChannel($article, $extractedData, $channel);

View file

@ -182,7 +182,7 @@
'defaults' => [ 'defaults' => [
'supervisor-1' => [ 'supervisor-1' => [
'connection' => 'redis', 'connection' => 'redis',
'queue' => ['default', 'publishing', 'feed-discovery'], 'queue' => ['default', 'publishing', 'feed-discovery', 'sync'],
'balance' => 'auto', 'balance' => 'auto',
'autoScalingStrategy' => 'time', 'autoScalingStrategy' => 'time',
'maxProcesses' => 1, 'maxProcesses' => 1,

View file

@ -26,6 +26,7 @@ public function definition(): array
'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'), 'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'),
'author' => $this->faker->optional()->name(), 'author' => $this->faker->optional()->name(),
'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']), 'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']),
'publish_status' => 'unpublished',
]; ];
} }
} }

View file

@ -20,10 +20,13 @@ public function up(): void
$table->string('author')->nullable(); $table->string('author')->nullable();
$table->unsignedBigInteger('feed_id')->nullable(); $table->unsignedBigInteger('feed_id')->nullable();
$table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending'); $table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending');
$table->timestamp('validated_at')->nullable();
$table->enum('publish_status', ['unpublished', 'publishing', 'published', 'error'])->default('unpublished');
$table->timestamps(); $table->timestamps();
$table->index(['published_at', 'approval_status']); $table->index(['published_at', 'approval_status']);
$table->index('feed_id'); $table->index('feed_id');
$table->unique('url');
}); });
// Article publications table // Article publications table
@ -71,4 +74,4 @@ public function down(): void
Schema::dropIfExists('logs'); Schema::dropIfExists('logs');
Schema::dropIfExists('settings'); Schema::dropIfExists('settings');
} }
}; };

View file

@ -64,20 +64,21 @@ public function up(): void
$table->unique(['platform_account_id', 'platform_channel_id'], 'account_channel_unique'); $table->unique(['platform_account_id', 'platform_channel_id'], 'account_channel_unique');
}); });
// Platform channel posts table // Platform channel posts table (synced from platform APIs for duplicate detection)
Schema::create('platform_channel_posts', function (Blueprint $table) { Schema::create('platform_channel_posts', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('platform_channel_id')->constrained()->onDelete('cascade'); $table->string('platform');
$table->string('channel_id');
$table->string('channel_name')->nullable();
$table->string('post_id'); $table->string('post_id');
$table->string('title'); $table->string('title')->nullable();
$table->text('content')->nullable();
$table->string('url')->nullable(); $table->string('url')->nullable();
$table->timestamp('posted_at'); $table->timestamp('posted_at')->nullable();
$table->string('author');
$table->json('metadata')->nullable();
$table->timestamps(); $table->timestamps();
$table->unique(['platform_channel_id', 'post_id'], 'channel_post_unique'); $table->unique(['platform', 'channel_id', 'post_id'], 'channel_post_unique');
$table->index(['platform', 'channel_id', 'url']);
$table->index(['platform', 'channel_id', 'title']);
}); });
// Language platform instance pivot table // Language platform instance pivot table
@ -102,4 +103,4 @@ public function down(): void
Schema::dropIfExists('platform_accounts'); Schema::dropIfExists('platform_accounts');
Schema::dropIfExists('platform_instances'); Schema::dropIfExists('platform_instances');
} }
}; };

View file

@ -1,69 +0,0 @@
# ===================
# FFR Production Services
# ===================
services:
app:
build:
context: ../..
dockerfile: Dockerfile
image: codeberg.org/lvl0/ffr:latest
container_name: ffr_app
restart: unless-stopped
ports:
- "8000:8000"
environment:
APP_NAME: "${APP_NAME:-FFR}"
APP_KEY: "${APP_KEY}"
APP_URL: "${APP_URL}"
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: "${DB_DATABASE:-ffr}"
DB_USERNAME: "${DB_USERNAME:-ffr}"
DB_PASSWORD: "${DB_PASSWORD}"
REDIS_HOST: redis
REDIS_PORT: 6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- ffr-network
db:
image: mariadb:11
container_name: ffr_db
restart: unless-stopped
environment:
MYSQL_DATABASE: "${DB_DATABASE:-ffr}"
MYSQL_USER: "${DB_USERNAME:-ffr}"
MYSQL_PASSWORD: "${DB_PASSWORD}"
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}"
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- ffr-network
redis:
image: redis:7-alpine
container_name: ffr_redis
restart: unless-stopped
volumes:
- redis_data:/data
networks:
- ffr-network
networks:
ffr-network:
driver: bridge
volumes:
db_data:
redis_data:

View file

@ -1,7 +1 @@
import './bootstrap'; import "./bootstrap";
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();

View file

@ -54,6 +54,20 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font
</svg> </svg>
Published Published
</span> </span>
@elseif ($article->publish_status === 'error')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
Publish Error
</span>
@elseif ($article->publish_status === 'publishing')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
<svg class="h-3 w-3 mr-1 animate-spin" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Publishing...
</span>
@elseif ($article->approval_status === 'approved') @elseif ($article->approval_status === 'approved')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">

View file

@ -16,11 +16,11 @@
</div> </div>
<div class="flex items-center text-sm text-gray-600"> <div class="flex items-center text-sm text-gray-600">
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">2</div> <div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">2</div>
<span>Add your first feed</span> <span>Configure a channel</span>
</div> </div>
<div class="flex items-center text-sm text-gray-600"> <div class="flex items-center text-sm text-gray-600">
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">3</div> <div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">3</div>
<span>Configure a channel</span> <span>Add your first feed</span>
</div> </div>
<div class="flex items-center text-sm text-gray-600"> <div class="flex items-center text-sm text-gray-600">
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">4</div> <div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">4</div>
@ -173,112 +173,8 @@ class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition
</div> </div>
@endif @endif
{{-- Step 3: Feed --}} {{-- Step 3: Channel --}}
@if ($step === 3) @if ($step === 3)
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Add Your First Feed</h1>
<p class="text-gray-600">
Choose from our supported news providers to monitor for new articles
</p>
{{-- Progress indicator --}}
<div class="flex justify-center mt-6 space-x-2">
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">2</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">3</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</div>
</div>
<form wire:submit="createFeed" class="space-y-6 mt-8 text-left">
@if (!empty($formErrors['general']))
<div class="p-3 bg-red-50 border border-red-200 rounded-md">
<p class="text-red-600 text-sm">{{ $formErrors['general'] }}</p>
</div>
@endif
<div>
<label for="feedName" class="block text-sm font-medium text-gray-700 mb-2">
Feed Name
</label>
<input
type="text"
id="feedName"
wire:model="feedName"
placeholder="My News Feed"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
@error('feedName') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="feedProvider" class="block text-sm font-medium text-gray-700 mb-2">
News Provider
</label>
<select
id="feedProvider"
wire:model="feedProvider"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select news provider</option>
@foreach ($feedProviders as $provider)
<option value="{{ $provider['code'] }}">{{ $provider['name'] }}</option>
@endforeach
</select>
@error('feedProvider') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="feedLanguageId" class="block text-sm font-medium text-gray-700 mb-2">
Language
</label>
<select
id="feedLanguageId"
wire:model="feedLanguageId"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select language</option>
@foreach ($languages as $language)
<option value="{{ $language->id }}">{{ $language->name }}</option>
@endforeach
</select>
@error('feedLanguageId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="feedDescription" class="block text-sm font-medium text-gray-700 mb-2">
Description (Optional)
</label>
<textarea
id="feedDescription"
wire:model="feedDescription"
rows="3"
placeholder="Brief description of this feed"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
></textarea>
@error('feedDescription') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex justify-between">
<button type="button" wire:click="previousStep" class="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200">
Back
</button>
<button
type="submit"
@disabled($isLoading)
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
>
{{ $isLoading ? 'Creating...' : 'Continue' }}
</button>
</div>
</form>
</div>
@endif
{{-- Step 4: Channel --}}
@if ($step === 4)
<div class="text-center mb-8"> <div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Configure Your Channel</h1> <h1 class="text-2xl font-bold text-gray-900 mb-2">Configure Your Channel</h1>
<p class="text-gray-600"> <p class="text-gray-600">
@ -288,8 +184,8 @@ class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition
{{-- Progress indicator --}} {{-- Progress indicator --}}
<div class="flex justify-center mt-6 space-x-2"> <div class="flex justify-center mt-6 space-x-2">
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div> <div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div> <div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">2</div>
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">3</div> <div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">3</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</div> <div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</div>
</div> </div>
@ -382,6 +278,110 @@ class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition
</div> </div>
@endif @endif
{{-- Step 4: Feed --}}
@if ($step === 4)
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Add Your First Feed</h1>
<p class="text-gray-600">
Choose from our supported news providers to monitor for new articles
</p>
{{-- Progress indicator --}}
<div class="flex justify-center mt-6 space-x-2">
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold"></div>
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">3</div>
<div class="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">4</div>
</div>
<form wire:submit="createFeed" class="space-y-6 mt-8 text-left">
@if (!empty($formErrors['general']))
<div class="p-3 bg-red-50 border border-red-200 rounded-md">
<p class="text-red-600 text-sm">{{ $formErrors['general'] }}</p>
</div>
@endif
<div>
<label for="feedName" class="block text-sm font-medium text-gray-700 mb-2">
Feed Name
</label>
<input
type="text"
id="feedName"
wire:model="feedName"
placeholder="My News Feed"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
@error('feedName') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="feedProvider" class="block text-sm font-medium text-gray-700 mb-2">
News Provider
</label>
<select
id="feedProvider"
wire:model="feedProvider"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select news provider</option>
@foreach ($feedProviders as $provider)
<option value="{{ $provider['code'] }}">{{ $provider['name'] }}</option>
@endforeach
</select>
@error('feedProvider') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="feedLanguageId" class="block text-sm font-medium text-gray-700 mb-2">
Language
</label>
<select
id="feedLanguageId"
wire:model="feedLanguageId"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select language</option>
@foreach ($languages as $language)
<option value="{{ $language->id }}">{{ $language->name }}</option>
@endforeach
</select>
@error('feedLanguageId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="feedDescription" class="block text-sm font-medium text-gray-700 mb-2">
Description (Optional)
</label>
<textarea
id="feedDescription"
wire:model="feedDescription"
rows="3"
placeholder="Brief description of this feed"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
></textarea>
@error('feedDescription') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex justify-between">
<button type="button" wire:click="previousStep" class="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200">
Back
</button>
<button
type="submit"
@disabled($isLoading)
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
>
{{ $isLoading ? 'Creating...' : 'Continue' }}
</button>
</div>
</form>
</div>
@endif
{{-- Step 5: Route --}} {{-- Step 5: Route --}}
@if ($step === 5) @if ($step === 5)
<div class="text-center mb-8"> <div class="text-center mb-8">

View file

@ -38,3 +38,14 @@
}); });
require __DIR__.'/auth.php'; require __DIR__.'/auth.php';
Route::get('/health', function () {
return response()->json(['status' => 'ok']);
});
Route::fallback(function () {
return response()->json([
'message' => 'This is the FFR API backend. Use /api/v1/* endpoints or check the React frontend.',
'api_base' => '/api/v1',
], 404);
});

View file

@ -81,32 +81,6 @@ pkgs.mkShell {
podman-compose -f $COMPOSE_FILE exec app php artisan "$@" podman-compose -f $COMPOSE_FILE exec app php artisan "$@"
} }
# ===================
# PROD COMMANDS
# ===================
prod-build() {
local tag="''${1:-latest}"
local image="codeberg.org/lvl0/ffr:$tag"
echo "Building production image: $image"
if ! podman build -t "$image" -f Dockerfile .; then
echo "Build failed!"
return 1
fi
echo ""
echo "Pushing to registry..."
if ! podman push "$image"; then
echo ""
echo "Push failed! You may need to login first:"
echo " podman login codeberg.org"
return 1
fi
echo ""
echo "Done! Image pushed: $image"
}
# =================== # ===================
# WELCOME MESSAGE # WELCOME MESSAGE
# =================== # ===================
@ -125,7 +99,6 @@ pkgs.mkShell {
echo " dev-logs-db Tail database logs" echo " dev-logs-db Tail database logs"
echo " dev-shell Shell into app container" echo " dev-shell Shell into app container"
echo " dev-artisan <cmd> Run artisan command" echo " dev-artisan <cmd> Run artisan command"
echo " prod-build [tag] Build and push prod image (default: latest)"
echo "" echo ""
echo "Services:" echo "Services:"
echo " app Laravel + Vite http://localhost:8000" echo " app Laravel + Vite http://localhost:8000"

View file

@ -48,7 +48,7 @@ public function test_update_modifies_article_processing_setting(): void
public function test_update_modifies_publishing_approvals_setting(): void public function test_update_modifies_publishing_approvals_setting(): void
{ {
$response = $this->putJson('/api/v1/settings', [ $response = $this->putJson('/api/v1/settings', [
'enable_publishing_approvals' => true, 'publishing_approvals_enabled' => true,
]); ]);
$response->assertStatus(200) $response->assertStatus(200)
@ -65,13 +65,13 @@ public function test_update_validates_boolean_values(): void
{ {
$response = $this->putJson('/api/v1/settings', [ $response = $this->putJson('/api/v1/settings', [
'article_processing_enabled' => 'not-a-boolean', 'article_processing_enabled' => 'not-a-boolean',
'enable_publishing_approvals' => 'also-not-boolean', 'publishing_approvals_enabled' => 'also-not-boolean',
]); ]);
$response->assertStatus(422) $response->assertStatus(422)
->assertJsonValidationErrors([ ->assertJsonValidationErrors([
'article_processing_enabled', 'article_processing_enabled',
'enable_publishing_approvals' 'publishing_approvals_enabled'
]); ]);
} }
@ -89,7 +89,7 @@ public function test_update_accepts_partial_updates(): void
'article_processing_enabled' => true, 'article_processing_enabled' => true,
] ]
]); ]);
// Should still have structure for both settings // Should still have structure for both settings
$response->assertJsonStructure([ $response->assertJsonStructure([
'data' => [ 'data' => [
@ -98,4 +98,4 @@ public function test_update_accepts_partial_updates(): void
] ]
]); ]);
} }
} }

View file

@ -84,11 +84,11 @@ public function test_sync_channel_posts_job_processes_successfully(): void
{ {
$channel = PlatformChannel::factory()->create(); $channel = PlatformChannel::factory()->create();
$job = new SyncChannelPostsJob($channel); $job = new SyncChannelPostsJob($channel);
// Test that job can be constructed and has correct properties // Test that job can be constructed and has correct properties
$this->assertEquals('sync', $job->queue); $this->assertEquals('sync', $job->queue);
$this->assertInstanceOf(SyncChannelPostsJob::class, $job); $this->assertInstanceOf(SyncChannelPostsJob::class, $job);
// Don't actually run the job to avoid HTTP calls // Don't actually run the job to avoid HTTP calls
$this->assertTrue(true); $this->assertTrue(true);
} }
@ -194,11 +194,10 @@ public function test_validate_article_listener_processes_new_article(): void
'full_article' => 'This is a test article about Belgium and Belgian politics.' 'full_article' => 'This is a test article about Belgium and Belgian politics.'
]); ]);
$validationService = app(ValidationService::class); $listener = app(ValidateArticleListener::class);
$listener = new ValidateArticleListener();
$event = new NewArticleFetched($article); $event = new NewArticleFetched($article);
$listener->handle($event, $validationService); $listener->handle($event);
$article->refresh(); $article->refresh();
$this->assertNotEquals('pending', $article->approval_status); $this->assertNotEquals('pending', $article->approval_status);

View file

@ -13,7 +13,7 @@ class NewArticleFetchedEventTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function test_new_article_fetched_event_dispatched_on_article_creation(): void public function test_new_article_fetched_event_dispatched_via_dispatch_method(): void
{ {
Event::fake([NewArticleFetched::class]); Event::fake([NewArticleFetched::class]);
@ -25,6 +25,8 @@ public function test_new_article_fetched_event_dispatched_on_article_creation():
'title' => 'Test Article', 'title' => 'Test Article',
]); ]);
$article->dispatchFetchedEvent();
Event::assertDispatched(NewArticleFetched::class, function (NewArticleFetched $event) use ($article) { Event::assertDispatched(NewArticleFetched::class, function (NewArticleFetched $event) use ($article) {
return $event->article->id === $article->id; return $event->article->id === $article->id;
}); });

View file

@ -34,11 +34,10 @@ public function test_listener_validates_article_and_dispatches_ready_to_publish_
'approval_status' => 'pending', 'approval_status' => 'pending',
]); ]);
$listener = new ValidateArticleListener(); $listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article); $event = new NewArticleFetched($article);
$validationService = app(ValidationService::class); $listener->handle($event);
$listener->handle($event, $validationService);
$article->refresh(); $article->refresh();
@ -62,11 +61,10 @@ public function test_listener_skips_already_validated_articles(): void
'approval_status' => 'approved', 'approval_status' => 'approved',
]); ]);
$listener = new ValidateArticleListener(); $listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article); $event = new NewArticleFetched($article);
$validationService = app(ValidationService::class); $listener->handle($event);
$listener->handle($event, $validationService);
Event::assertNotDispatched(ArticleReadyToPublish::class); Event::assertNotDispatched(ArticleReadyToPublish::class);
} }
@ -90,11 +88,10 @@ public function test_listener_skips_articles_with_existing_publication(): void
'published_by' => 'test-user', 'published_by' => 'test-user',
]); ]);
$listener = new ValidateArticleListener(); $listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article); $event = new NewArticleFetched($article);
$validationService = app(ValidationService::class); $listener->handle($event);
$listener->handle($event, $validationService);
Event::assertNotDispatched(ArticleReadyToPublish::class); Event::assertNotDispatched(ArticleReadyToPublish::class);
} }
@ -115,11 +112,10 @@ public function test_listener_calls_validation_service(): void
'approval_status' => 'pending', 'approval_status' => 'pending',
]); ]);
$listener = new ValidateArticleListener(); $listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article); $event = new NewArticleFetched($article);
$validationService = app(ValidationService::class); $listener->handle($event);
$listener->handle($event, $validationService);
// Verify that the article was processed by ValidationService // Verify that the article was processed by ValidationService
$article->refresh(); $article->refresh();

View file

@ -19,12 +19,12 @@ class ArticleTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
// Mock HTTP requests to prevent external calls // Mock HTTP requests to prevent external calls
Http::fake([ Http::fake([
'*' => Http::response('', 500) '*' => Http::response('', 500)
]); ]);
// Don't fake events globally - let individual tests control this // Don't fake events globally - let individual tests control this
} }
@ -180,18 +180,17 @@ public function test_feed_relationship(): void
$this->assertEquals($feed->id, $article->feed->id); $this->assertEquals($feed->id, $article->feed->id);
} }
public function test_article_creation_fires_new_article_fetched_event(): void public function test_dispatch_fetched_event_fires_new_article_fetched_event(): void
{ {
$eventFired = false; Event::fake([NewArticleFetched::class]);
// Listen for the event using a closure
Event::listen(NewArticleFetched::class, function ($event) use (&$eventFired) {
$eventFired = true;
});
$feed = Feed::factory()->create();
Article::factory()->create(['feed_id' => $feed->id]);
$this->assertTrue($eventFired, 'NewArticleFetched event was not fired'); $feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id]);
$article->dispatchFetchedEvent();
Event::assertDispatched(NewArticleFetched::class, function ($event) use ($article) {
return $event->article->id === $article->id;
});
} }
} }

View file

@ -3,20 +3,15 @@
namespace Tests\Unit\Modules\Lemmy\Services; namespace Tests\Unit\Modules\Lemmy\Services;
use App\Modules\Lemmy\Services\LemmyApiService; use App\Modules\Lemmy\Services\LemmyApiService;
use App\Models\PlatformChannelPost;
use App\Enums\PlatformEnum; use App\Enums\PlatformEnum;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Tests\TestCase; use Tests\TestCase;
use Mockery;
use Exception; use Exception;
class LemmyApiServiceTest extends TestCase class LemmyApiServiceTest extends TestCase
{ {
protected function tearDown(): void use RefreshDatabase;
{
parent::tearDown();
}
public function test_constructor_sets_instance(): void public function test_constructor_sets_instance(): void
{ {
@ -100,8 +95,6 @@ public function test_login_returns_null_on_unsuccessful_response(): void
'*' => Http::response(['error' => 'Invalid credentials'], 401) '*' => Http::response(['error' => 'Invalid credentials'], 401)
]); ]);
Log::shouldReceive('error')->twice(); // Once for HTTPS, once for HTTP fallback
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$token = $service->login('user', 'wrong'); $token = $service->login('user', 'wrong');
@ -114,19 +107,12 @@ public function test_login_handles_rate_limit_error(): void
'*' => Http::response('{"error":"rate_limit_error"}', 429) '*' => Http::response('{"error":"rate_limit_error"}', 429)
]); ]);
// Expecting 4 error logs:
// 1. 'Lemmy login failed' for HTTPS attempt
// 2. 'Lemmy login exception' for catching the rate limit exception on HTTPS
// 3. 'Lemmy login failed' for HTTP attempt
// 4. 'Lemmy login exception' for catching the rate limit exception on HTTP
Log::shouldReceive('error')->times(4);
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$result = $service->login('user', 'pass');
// Since the exception is caught and HTTP is tried, then that also fails, $this->expectException(Exception::class);
// the method returns null instead of throwing $this->expectExceptionMessage('Rate limited');
$this->assertNull($result);
$service->login('user', 'pass');
} }
public function test_login_returns_null_when_jwt_missing_from_response(): void public function test_login_returns_null_when_jwt_missing_from_response(): void
@ -141,18 +127,18 @@ public function test_login_returns_null_when_jwt_missing_from_response(): void
$this->assertNull($token); $this->assertNull($token);
} }
public function test_login_handles_exception_and_returns_null(): void public function test_login_handles_exception_and_throws(): void
{ {
Http::fake(function () { Http::fake(function () {
throw new Exception('Network error'); throw new Exception('Network error');
}); });
Log::shouldReceive('error')->twice();
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$token = $service->login('user', 'pass');
$this->assertNull($token); $this->expectException(Exception::class);
$this->expectExceptionMessage('Connection failed');
$service->login('user', 'pass');
} }
public function test_get_community_id_success(): void public function test_get_community_id_success(): void
@ -183,8 +169,6 @@ public function test_get_community_id_throws_on_unsuccessful_response(): void
'*' => Http::response('Not found', 404) '*' => Http::response('Not found', 404)
]); ]);
Log::shouldReceive('error')->once();
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$this->expectException(Exception::class); $this->expectException(Exception::class);
@ -199,8 +183,6 @@ public function test_get_community_id_throws_when_community_not_in_response(): v
'*' => Http::response(['success' => true], 200) '*' => Http::response(['success' => true], 200)
]); ]);
Log::shouldReceive('error')->once();
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$this->expectException(Exception::class); $this->expectException(Exception::class);
@ -234,21 +216,6 @@ public function test_sync_channel_posts_success(): void
], 200) ], 200)
]); ]);
Log::shouldReceive('info')->once()->with('Synced channel posts', Mockery::any());
$mockPost = Mockery::mock('alias:' . PlatformChannelPost::class);
$mockPost->shouldReceive('storePost')
->twice()
->with(
PlatformEnum::LEMMY,
Mockery::any(),
'test-community',
Mockery::any(),
Mockery::any(),
Mockery::any(),
Mockery::any()
);
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$service->syncChannelPosts('token', 42, 'test-community'); $service->syncChannelPosts('token', 42, 'test-community');
@ -258,6 +225,25 @@ public function test_sync_channel_posts_success(): void
&& str_contains($request->url(), 'limit=50') && str_contains($request->url(), 'limit=50')
&& str_contains($request->url(), 'sort=New'); && str_contains($request->url(), 'sort=New');
}); });
// Verify posts were stored in the database
$this->assertDatabaseHas('platform_channel_posts', [
'platform' => PlatformEnum::LEMMY->value,
'channel_id' => '42',
'channel_name' => 'test-community',
'post_id' => '1',
'url' => 'https://example.com/1',
'title' => 'Post 1',
]);
$this->assertDatabaseHas('platform_channel_posts', [
'platform' => PlatformEnum::LEMMY->value,
'channel_id' => '42',
'channel_name' => 'test-community',
'post_id' => '2',
'url' => 'https://example.com/2',
'title' => 'Post 2',
]);
} }
public function test_sync_channel_posts_handles_unsuccessful_response(): void public function test_sync_channel_posts_handles_unsuccessful_response(): void
@ -266,12 +252,11 @@ public function test_sync_channel_posts_handles_unsuccessful_response(): void
'*' => Http::response('Error', 500) '*' => Http::response('Error', 500)
]); ]);
Log::shouldReceive('warning')->once()->with('Failed to sync channel posts', Mockery::any());
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$service->syncChannelPosts('token', 42, 'test-community'); $service->syncChannelPosts('token', 42, 'test-community');
Http::assertSentCount(1); Http::assertSentCount(1);
$this->assertDatabaseCount('platform_channel_posts', 0);
} }
public function test_sync_channel_posts_handles_exception(): void public function test_sync_channel_posts_handles_exception(): void
@ -280,8 +265,6 @@ public function test_sync_channel_posts_handles_exception(): void
throw new Exception('Network error'); throw new Exception('Network error');
}); });
Log::shouldReceive('error')->once()->with('Exception while syncing channel posts', Mockery::any());
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$service->syncChannelPosts('token', 42, 'test-community'); $service->syncChannelPosts('token', 42, 'test-community');
@ -354,8 +337,6 @@ public function test_create_post_throws_on_unsuccessful_response(): void
'*' => Http::response('Forbidden', 403) '*' => Http::response('Forbidden', 403)
]); ]);
Log::shouldReceive('error')->once();
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$this->expectException(Exception::class); $this->expectException(Exception::class);
@ -393,8 +374,6 @@ public function test_get_languages_returns_empty_array_on_failure(): void
'*' => Http::response('Error', 500) '*' => Http::response('Error', 500)
]); ]);
Log::shouldReceive('warning')->once();
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$languages = $service->getLanguages(); $languages = $service->getLanguages();
@ -407,8 +386,6 @@ public function test_get_languages_handles_exception(): void
throw new Exception('Network error'); throw new Exception('Network error');
}); });
Log::shouldReceive('error')->once();
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$languages = $service->getLanguages(); $languages = $service->getLanguages();

View file

@ -9,6 +9,7 @@
use App\Models\Feed; use App\Models\Feed;
use App\Models\PlatformAccount; use App\Models\PlatformAccount;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\PlatformChannelPost;
use App\Models\PlatformInstance; use App\Models\PlatformInstance;
use App\Models\Route; use App\Models\Route;
use App\Modules\Lemmy\Services\LemmyPublisher; use App\Modules\Lemmy\Services\LemmyPublisher;
@ -105,7 +106,7 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe
// Arrange // Arrange
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']); $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']);
$platformInstance = PlatformInstance::factory()->create(); $platformInstance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$account = PlatformAccount::factory()->create(); $account = PlatformAccount::factory()->create();
@ -151,7 +152,7 @@ public function test_publish_to_routed_channels_handles_publishing_failure_grace
// Arrange // Arrange
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']); $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']);
$platformInstance = PlatformInstance::factory()->create(); $platformInstance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$account = PlatformAccount::factory()->create(); $account = PlatformAccount::factory()->create();
@ -296,4 +297,162 @@ public function test_publish_to_routed_channels_filters_out_failed_publications(
$this->assertDatabaseHas('article_publications', ['post_id' => 300]); $this->assertDatabaseHas('article_publications', ['post_id' => 300]);
$this->assertDatabaseCount('article_publications', 1); $this->assertDatabaseCount('article_publications', 1);
} }
public function test_publish_skips_duplicate_when_url_already_posted_to_channel(): void
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'url' => 'https://example.com/article-1',
]);
$platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']);
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$account = PlatformAccount::factory()->create();
Route::create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => true,
'priority' => 50,
]);
$channel->platformAccounts()->attach($account->id, [
'is_active' => true,
'priority' => 50,
]);
// Simulate the URL already being posted to this channel (synced from Lemmy)
PlatformChannelPost::storePost(
PlatformEnum::LEMMY,
(string) $channel->channel_id,
$channel->name,
'999',
'https://example.com/article-1',
'Different Title',
);
// Publisher should never be called
$publisherDouble = \Mockery::mock(LemmyPublisher::class);
$publisherDouble->shouldNotReceive('publishToChannel');
$service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble);
// Act
$result = $service->publishToRoutedChannels($article, ['title' => 'Some Title']);
// Assert
$this->assertTrue($result->isEmpty());
$this->assertDatabaseCount('article_publications', 0);
}
public function test_publish_skips_duplicate_when_title_already_posted_to_channel(): void
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'url' => 'https://example.com/article-new-url',
'title' => 'Breaking News: Something Happened',
]);
$platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']);
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$account = PlatformAccount::factory()->create();
Route::create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => true,
'priority' => 50,
]);
$channel->platformAccounts()->attach($account->id, [
'is_active' => true,
'priority' => 50,
]);
// Simulate the same title already posted with a different URL
PlatformChannelPost::storePost(
PlatformEnum::LEMMY,
(string) $channel->channel_id,
$channel->name,
'888',
'https://example.com/different-url',
'Breaking News: Something Happened',
);
// Publisher should never be called
$publisherDouble = \Mockery::mock(LemmyPublisher::class);
$publisherDouble->shouldNotReceive('publishToChannel');
$service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble);
// Act
$result = $service->publishToRoutedChannels($article, ['title' => 'Breaking News: Something Happened']);
// Assert
$this->assertTrue($result->isEmpty());
$this->assertDatabaseCount('article_publications', 0);
}
public function test_publish_proceeds_when_no_duplicate_exists(): void
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'url' => 'https://example.com/unique-article',
]);
$platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']);
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$account = PlatformAccount::factory()->create();
Route::create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => true,
'priority' => 50,
]);
$channel->platformAccounts()->attach($account->id, [
'is_active' => true,
'priority' => 50,
]);
// Existing post in the channel has a completely different URL and title
PlatformChannelPost::storePost(
PlatformEnum::LEMMY,
(string) $channel->channel_id,
$channel->name,
'777',
'https://example.com/other-article',
'Totally Different Title',
);
$publisherDouble = \Mockery::mock(LemmyPublisher::class);
$publisherDouble->shouldReceive('publishToChannel')
->once()
->andReturn(['post_view' => ['post' => ['id' => 456]]]);
$service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble);
// Act
$result = $service->publishToRoutedChannels($article, ['title' => 'Unique Title']);
// Assert
$this->assertCount(1, $result);
$this->assertDatabaseHas('article_publications', [
'article_id' => $article->id,
'post_id' => 456,
]);
}
} }

View file

@ -3,15 +3,17 @@
namespace Tests\Unit\Services; namespace Tests\Unit\Services;
use App\Services\SystemStatusService; use App\Services\SystemStatusService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
class SystemStatusServiceTest extends TestCase class SystemStatusServiceTest extends TestCase
{ {
use RefreshDatabase;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
// Mock HTTP requests to prevent external calls // Mock HTTP requests to prevent external calls
Http::fake([ Http::fake([
'*' => Http::response('', 500) '*' => Http::response('', 500)
@ -34,11 +36,11 @@ public function test_get_system_status_returns_correct_structure(): void
$this->assertArrayHasKey('status', $status); $this->assertArrayHasKey('status', $status);
$this->assertArrayHasKey('status_class', $status); $this->assertArrayHasKey('status_class', $status);
$this->assertArrayHasKey('reasons', $status); $this->assertArrayHasKey('reasons', $status);
// Without database setup, system should be disabled // Without database setup, system should be disabled
$this->assertFalse($status['is_enabled']); $this->assertFalse($status['is_enabled']);
$this->assertEquals('Disabled', $status['status']); $this->assertEquals('Disabled', $status['status']);
$this->assertEquals('text-red-600', $status['status_class']); $this->assertEquals('text-red-600', $status['status_class']);
$this->assertIsArray($status['reasons']); $this->assertIsArray($status['reasons']);
} }
} }