diff --git a/app/Console/Commands/FetchArticleCommand.php b/app/Console/Commands/FetchArticleCommand.php deleted file mode 100644 index 0b28190..0000000 --- a/app/Console/Commands/FetchArticleCommand.php +++ /dev/null @@ -1,27 +0,0 @@ - $this->argument('url'), - ]); - - $res = ArticleFetcher::fetchArticleData($article); - - dump($res); - - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/FetchNewArticlesCommand.php b/app/Console/Commands/FetchNewArticlesCommand.php index e2ba1db..a4eaf5d 100644 --- a/app/Console/Commands/FetchNewArticlesCommand.php +++ b/app/Console/Commands/FetchNewArticlesCommand.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use App\Services\Article\ArticleFetcher; +use App\Jobs\ArticleDiscoveryJob; use Illuminate\Console\Command; class FetchNewArticlesCommand extends Command @@ -13,9 +13,7 @@ class FetchNewArticlesCommand extends Command public function handle(): int { - logger('Fetch new articles command'); - - ArticleFetcher::getNewArticles(); + ArticleDiscoveryJob::dispatch(); return self::SUCCESS; } diff --git a/app/Console/Commands/SyncChannelPostsCommand.php b/app/Console/Commands/SyncChannelPostsCommand.php index f41e8d9..6714677 100644 --- a/app/Console/Commands/SyncChannelPostsCommand.php +++ b/app/Console/Commands/SyncChannelPostsCommand.php @@ -2,59 +2,33 @@ namespace App\Console\Commands; -use App\Enums\PlatformEnum; use App\Jobs\SyncChannelPostsJob; -use App\Modules\Lemmy\Services\LemmyApiService; use Illuminate\Console\Command; class SyncChannelPostsCommand extends Command { protected $signature = 'channel:sync {platform=lemmy}'; - + protected $description = 'Manually sync channel posts for a platform'; public function handle(): int { $platform = $this->argument('platform'); - + if ($platform === 'lemmy') { return $this->syncLemmy(); } - + $this->error("Unsupported platform: {$platform}"); + return self::FAILURE; } private function syncLemmy(): int { - $communityName = config('lemmy.community'); - - if (!$communityName) { - $this->error('Missing Lemmy community configuration (lemmy.community)'); - return self::FAILURE; - } + SyncChannelPostsJob::dispatchForAllActiveChannels(); + $this->info('Successfully dispatched sync jobs for all active Lemmy channels'); - try { - $this->info("Getting community ID for: {$communityName}"); - - $api = new LemmyApiService(config('lemmy.instance')); - $communityId = $api->getCommunityId($communityName); - - $this->info("Running sync job for Lemmy community: {$communityName} (ID: {$communityId})"); - - SyncChannelPostsJob::dispatchSync( - PlatformEnum::LEMMY, - (string) $communityId, - $communityName - ); - - $this->info('Channel posts synced successfully'); - - return self::SUCCESS; - - } catch (\Exception $e) { - $this->error("Failed to sync channel posts: {$e->getMessage()}"); - return self::FAILURE; - } + return self::SUCCESS; } } diff --git a/app/Events/ArticleFetched.php b/app/Events/NewArticleFetched.php similarity index 93% rename from app/Events/ArticleFetched.php rename to app/Events/NewArticleFetched.php index 64d2cfd..3e657f9 100644 --- a/app/Events/ArticleFetched.php +++ b/app/Events/NewArticleFetched.php @@ -7,12 +7,11 @@ use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class ArticleFetched +class NewArticleFetched { use Dispatchable, InteractsWithSockets, SerializesModels; public function __construct(public Article $article) { - } } diff --git a/app/Exceptions/ChannelException.php b/app/Exceptions/ChannelException.php new file mode 100644 index 0000000..5f526b6 --- /dev/null +++ b/app/Exceptions/ChannelException.php @@ -0,0 +1,9 @@ +id} to {$platform->value}"; + $message = "Failed to publish article #$article->id"; + + if ($this->platform) { + $message .= " to $platform->value"; + } if ($previous) { $message .= ": {$previous->getMessage()}"; @@ -28,7 +32,7 @@ public function getArticle(): Article return $this->article; } - public function getPlatform(): PlatformEnum + public function getPlatform(): ?PlatformEnum { return $this->platform; } diff --git a/app/Exceptions/RoutingException.php b/app/Exceptions/RoutingException.php new file mode 100644 index 0000000..acbc082 --- /dev/null +++ b/app/Exceptions/RoutingException.php @@ -0,0 +1,9 @@ +name, + $feed->language, + $channel->name, + $channel->language + ); + + parent::__construct($message); + } +} diff --git a/app/Http/Controllers/RoutingController.php b/app/Http/Controllers/RoutingController.php new file mode 100644 index 0000000..e39bd2d --- /dev/null +++ b/app/Http/Controllers/RoutingController.php @@ -0,0 +1,165 @@ +where('is_active', true) + ->orderBy('name') + ->get(); + + $channels = PlatformChannel::with(['platformInstance', 'feeds']) + ->where('is_active', true) + ->orderBy('name') + ->get(); + + return view('pages.routing.index', compact('feeds', 'channels')); + } + + public function create(): View + { + $feeds = Feed::where('is_active', true) + ->orderBy('name') + ->get(); + + $channels = PlatformChannel::with('platformInstance') + ->where('is_active', true) + ->orderBy('name') + ->get(); + + return view('pages.routing.create', compact('feeds', 'channels')); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'feed_id' => 'required|exists:feeds,id', + 'channel_ids' => 'required|array|min:1', + 'channel_ids.*' => 'exists:platform_channels,id', + 'priority' => 'integer|min:0|max:100', + 'filters' => 'nullable|string' + ]); + + $feed = Feed::findOrFail($validated['feed_id']); + $channels = PlatformChannel::findMany($validated['channel_ids']); + $priority = $validated['priority'] ?? 0; + + try { + app(RoutingValidationService::class)->validateLanguageCompatibility($feed, $channels); + } catch (RoutingMismatchException $e) { + return redirect()->back() + ->withInput() + ->withErrors(['language' => $e->getMessage()]); + } + + $filters = $this->parseJsonFilters($validated['filters'] ?? null); + + // Attach channels to feed + $syncData = []; + foreach ($validated['channel_ids'] as $channelId) { + $syncData[$channelId] = [ + 'is_active' => true, + 'priority' => $priority, + 'filters' => $filters, + 'created_at' => now(), + 'updated_at' => now() + ]; + } + + $feed->channels()->syncWithoutDetaching($syncData); + + return redirect()->route('routing.index') + ->with('success', 'Feed routing created successfully!'); + } + + public function edit(Feed $feed, PlatformChannel $channel): View + { + $routing = $feed->channels() + ->wherePivot('platform_channel_id', $channel->id) + ->first(); + + if (! $routing) { + abort(404, 'Routing not found'); + } + + return view('pages.routing.edit', compact('feed', 'channel', 'routing')); + } + + public function update(Request $request, Feed $feed, PlatformChannel $channel): RedirectResponse + { + $validated = $request->validate([ + 'is_active' => 'boolean', + 'priority' => 'integer|min:0|max:100', + 'filters' => 'nullable|string' + ]); + + $filters = $this->parseJsonFilters($validated['filters'] ?? null); + + $feed->channels()->updateExistingPivot($channel->id, [ + 'is_active' => $validated['is_active'] ?? true, + 'priority' => $validated['priority'] ?? 0, + 'filters' => $filters, + 'updated_at' => now() + ]); + + return redirect()->route('routing.index') + ->with('success', 'Routing updated successfully!'); + } + + public function destroy(Feed $feed, PlatformChannel $channel): RedirectResponse + { + $feed->channels()->detach($channel->id); + + return redirect()->route('routing.index') + ->with('success', 'Routing deleted successfully!'); + } + + public function toggle(Request $request, Feed $feed, PlatformChannel $channel): RedirectResponse + { + $routing = $feed->channels() + ->wherePivot('platform_channel_id', $channel->id) + ->first(); + + if (! $routing) { + abort(404, 'Routing not found'); + } + + $newStatus = ! $routing->pivot->is_active; + + $feed->channels()->updateExistingPivot($channel->id, [ + 'is_active' => $newStatus, + 'updated_at' => now() + ]); + + $status = $newStatus ? 'activated' : 'deactivated'; + + return redirect()->route('routing.index') + ->with('success', "Routing {$status} successfully!"); + } + + private function parseJsonFilters(?string $json): ?array + { + if (empty($json)) { + return null; + } + + $decoded = json_decode($json, true); + + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + + return null; + } +} diff --git a/app/Jobs/ArticleDiscoveryForFeedJob.php b/app/Jobs/ArticleDiscoveryForFeedJob.php new file mode 100644 index 0000000..ac26406 --- /dev/null +++ b/app/Jobs/ArticleDiscoveryForFeedJob.php @@ -0,0 +1,61 @@ +onQueue('feed-discovery'); + } + + public function handle(): void + { + LogSaver::info('Starting feed article fetch', null, [ + 'feed_id' => $this->feed->id, + 'feed_name' => $this->feed->name, + 'feed_url' => $this->feed->url + ]); + + $articles = ArticleFetcher::getArticlesFromFeed($this->feed); + + LogSaver::info('Feed article fetch completed', null, [ + 'feed_id' => $this->feed->id, + 'feed_name' => $this->feed->name, + 'articles_count' => $articles->count() + ]); + + $this->feed->update(['last_fetched_at' => now()]); + } + + public static function dispatchForAllActiveFeeds(): void + { + Feed::where('is_active', true) + ->get() + ->each(function (Feed $feed, $index) { + // Space jobs apart to avoid overwhelming feeds + $delayMinutes = $index * self::FEED_DISCOVERY_DELAY_MINUTES; + + self::dispatch($feed) + ->delay(now()->addMinutes($delayMinutes)) + ->onQueue('feed-discovery'); + + LogSaver::info('Dispatched feed discovery job', null, [ + 'feed_id' => $feed->id, + 'feed_name' => $feed->name, + 'delay_minutes' => $delayMinutes + ]); + }); + } +} diff --git a/app/Jobs/ArticleDiscoveryJob.php b/app/Jobs/ArticleDiscoveryJob.php new file mode 100644 index 0000000..fee0a47 --- /dev/null +++ b/app/Jobs/ArticleDiscoveryJob.php @@ -0,0 +1,26 @@ +onQueue('feed-discovery'); + } + + public function handle(): void + { + LogSaver::info('Starting article discovery for all active feeds'); + + ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); + + LogSaver::info('Article discovery jobs dispatched for all active feeds'); + } +} diff --git a/app/Jobs/PublishToLemmyJob.php b/app/Jobs/PublishToLemmyJob.php index 0fa49d9..15bb287 100644 --- a/app/Jobs/PublishToLemmyJob.php +++ b/app/Jobs/PublishToLemmyJob.php @@ -4,8 +4,8 @@ use App\Exceptions\PublishException; use App\Models\Article; -use App\Modules\Lemmy\Services\LemmyPublisher; use App\Services\Article\ArticleFetcher; +use App\Services\Publishing\ArticlePublishingService; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use RuntimeException; @@ -22,37 +22,14 @@ public function __construct( public function handle(): void { - if ($this->article->articlePublication !== null) { - logger()->info('Article already published, skipping', [ - 'article_id' => $this->article->id - ]); - - return; - } - $extractedData = ArticleFetcher::fetchArticleData($this->article); - logger()->info('Publishing article to Lemmy', [ - 'article_id' => $this->article->id, - 'url' => $this->article->url - ]); + /** @var ArticlePublishingService $publishingService */ + $publishingService = resolve(ArticlePublishingService::class); try { - $publications = LemmyPublisher::fromActiveAccount()->publish($this->article, $extractedData); - - logger()->info('Article published successfully', [ - 'article_id' => $this->article->id, - 'publications_count' => $publications->count(), - 'communities' => $publications->pluck('community_id')->toArray() - ]); - - } catch (PublishException $e) { - $this->fail($e); - } catch (RuntimeException $e) { - logger()->warning('No active Lemmy accounts configured', [ - 'article_id' => $this->article->id, - 'error' => $e->getMessage() - ]); + $publishingService->publishToRoutedChannels($this->article, $extractedData); + } catch (PublishException|RuntimeException $e) { $this->fail($e); } } diff --git a/app/Jobs/RefreshArticlesJob.php b/app/Jobs/RefreshArticlesJob.php deleted file mode 100644 index a5ebb73..0000000 --- a/app/Jobs/RefreshArticlesJob.php +++ /dev/null @@ -1,22 +0,0 @@ -onQueue('lemmy-posts'); - } - - public function handle(): void - { - ArticleFetcher::getNewArticles(); - } -} diff --git a/app/Jobs/SyncChannelPostsJob.php b/app/Jobs/SyncChannelPostsJob.php index 1b5a9ea..5754d5c 100644 --- a/app/Jobs/SyncChannelPostsJob.php +++ b/app/Jobs/SyncChannelPostsJob.php @@ -4,102 +4,106 @@ use App\Enums\PlatformEnum; use App\Exceptions\PlatformAuthException; +use App\Models\PlatformChannel; use App\Modules\Lemmy\Services\LemmyApiService; +use App\Services\Log\LogSaver; use Exception; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Cache; -class SyncChannelPostsJob implements ShouldQueue +class SyncChannelPostsJob implements ShouldQueue, ShouldBeUnique { use Queueable; public function __construct( - private readonly PlatformEnum $platform, - private readonly string $channelId, - private readonly string $channelName + private readonly PlatformChannel $channel ) { $this->onQueue('lemmy-posts'); } - public static function dispatchForLemmy(): void + public static function dispatchForAllActiveChannels(): void { - $communityId = config('lemmy.community_id'); - $communityName = config('lemmy.community'); - - if ($communityName) { - // Use a placeholder ID if community_id is not set - we'll resolve it in the job - $communityId = $communityId ?: 'resolve_from_name'; - self::dispatch(PlatformEnum::LEMMY, (string) $communityId, $communityName); - } else { - logger()->warning('Cannot dispatch Lemmy sync job: missing community configuration'); - } + PlatformChannel::with(['platformInstance', 'platformAccounts']) + ->whereHas('platformInstance', fn ($query) => $query->where('platform', PlatformEnum::LEMMY)) + ->whereHas('platformAccounts', fn ($query) => $query->where('is_active', true)) + ->where('is_active', true) + ->get() + ->each(function (PlatformChannel $channel) { + self::dispatch($channel); + LogSaver::info('Dispatched sync job for channel', $channel); + }); } public function handle(): void { - echo "Starting channel posts sync job...\n"; - - if ($this->platform === PlatformEnum::LEMMY) { + LogSaver::info('Starting channel posts sync job', $this->channel); + + if ($this->channel->platformInstance->platform === PlatformEnum::LEMMY) { $this->syncLemmyChannelPosts(); } - - echo "Channel posts sync job completed!\n"; + + LogSaver::info('Channel posts sync job completed', $this->channel); } + /** + * @throws PlatformAuthException + */ private function syncLemmyChannelPosts(): void { try { - $api = new LemmyApiService(config('lemmy.instance')); - $token = $this->getAuthToken($api); + $account = $this->channel->platformAccounts()->where('is_active', true)->first(); - // Resolve community ID if it's a placeholder - $communityId = $this->channelId === 'resolve_from_name' - ? $api->getCommunityId($this->channelName) - : (int) $this->channelId; + if (!$account) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'No active account found for channel'); + } - $api->syncChannelPosts($token, $communityId, $this->channelName); + $api = new LemmyApiService($this->channel->platformInstance->url); + $token = $this->getAuthToken($api, $account); - logger()->info('Channel posts synced successfully', [ - 'platform' => $this->platform->value, - 'channel_id' => $this->channelId, - 'channel_name' => $this->channelName - ]); + // Get community ID from channel_id or resolve from name + $communityId = $this->channel->channel_id + ? (int) $this->channel->channel_id + : $api->getCommunityId($this->channel->name); + + $api->syncChannelPosts($token, $communityId, $this->channel->name); + + LogSaver::info('Channel posts synced successfully', $this->channel); } catch (Exception $e) { - logger()->error('Failed to sync channel posts', [ - 'platform' => $this->platform->value, - 'channel_id' => $this->channelId, + LogSaver::error('Failed to sync channel posts', $this->channel, [ 'error' => $e->getMessage() ]); - + throw $e; } } - private function getAuthToken(LemmyApiService $api): string + /** + * @throws PlatformAuthException + */ + private function getAuthToken(LemmyApiService $api, \App\Models\PlatformAccount $account): string { - $cachedToken = Cache::get('lemmy_jwt_token'); - + $cacheKey = "lemmy_jwt_token_{$account->id}"; + $cachedToken = Cache::get($cacheKey); + if ($cachedToken) { return $cachedToken; } - $username = config('lemmy.username'); - $password = config('lemmy.password'); - - if (!$username || !$password) { - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials'); + if (!$account->username || !$account->password) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account'); } - $token = $api->login($username, $password); - + $token = $api->login($account->username, $account->password); + if (!$token) { - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed'); + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account'); } - Cache::put('lemmy_jwt_token', $token, 3600); - + Cache::put($cacheKey, $token, 3600); + return $token; } } diff --git a/app/Listeners/PublishArticle.php b/app/Listeners/PublishArticle.php index 0d7cd25..7c3d98d 100644 --- a/app/Listeners/PublishArticle.php +++ b/app/Listeners/PublishArticle.php @@ -14,19 +14,18 @@ class PublishArticle implements ShouldQueue public int $backoff = 300; public function __construct() - { - } + {} public function handle(ArticleReadyToPublish $event): void { $article = $event->article; - // Check if already published to avoid duplicate jobs if ($article->articlePublication()->exists()) { logger()->info('Article already published, skipping job dispatch', [ 'article_id' => $article->id, 'url' => $article->url ]); + return; } diff --git a/app/Listeners/ValidateArticle.php b/app/Listeners/ValidateArticleListener.php similarity index 83% rename from app/Listeners/ValidateArticle.php rename to app/Listeners/ValidateArticleListener.php index c71403b..f8aca2e 100644 --- a/app/Listeners/ValidateArticle.php +++ b/app/Listeners/ValidateArticleListener.php @@ -2,19 +2,16 @@ namespace App\Listeners; -use App\Events\ArticleFetched; +use App\Events\NewArticleFetched; use App\Events\ArticleReadyToPublish; use App\Services\Article\ValidationService; use Illuminate\Contracts\Queue\ShouldQueue; -class ValidateArticle implements ShouldQueue +class ValidateArticleListener implements ShouldQueue { - public string|null $queue = 'default'; + public string $queue = 'default'; - public function __construct() - {} - - public function handle(ArticleFetched $event): void + public function handle(NewArticleFetched $event): void { $article = $event->article; diff --git a/app/Models/Article.php b/app/Models/Article.php index 55430c4..daaf7f2 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -2,11 +2,11 @@ namespace App\Models; -use App\Events\ArticleFetched; +use App\Events\NewArticleFetched; use Database\Factories\ArticleFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Carbon; @@ -15,6 +15,8 @@ * @method static where(string $string, string $url) * @method static create(string[] $array) * @property integer $id + * @property int $feed_id + * @property Feed $feed * @property string $url * @property bool|null $is_valid * @property Carbon|null $validated_at @@ -28,6 +30,7 @@ class Article extends Model use HasFactory; protected $fillable = [ + 'feed_id', 'url', 'title', 'description', @@ -67,15 +70,15 @@ public function articlePublication(): HasOne return $this->hasOne(ArticlePublication::class); } - public function articlePublications(): HasMany + public function feed(): BelongsTo { - return $this->hasMany(ArticlePublication::class); + return $this->belongsTo(Feed::class); } protected static function booted(): void { static::created(function ($article) { - event(new ArticleFetched($article)); + event(new NewArticleFetched($article)); }); } } diff --git a/app/Models/Feed.php b/app/Models/Feed.php index afa55be..8ca3368 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -3,6 +3,9 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Carbon; /** @@ -10,7 +13,8 @@ * @property string $name * @property string $url * @property string $type - * @property string $language + * @property int $language_id + * @property Language $language * @property string $description * @property array $settings * @property bool $is_active @@ -19,14 +23,19 @@ * @property Carbon $updated_at * @method static create(array $validated) * @method static orderBy(string $string, string $string1) + * @method static where(string $string, true $true) + * @method static findOrFail(mixed $feed_id) */ class Feed extends Model { + private const RECENT_FETCH_THRESHOLD_HOURS = 2; + private const DAILY_FETCH_THRESHOLD_HOURS = 24; + protected $fillable = [ 'name', 'url', 'type', - 'language', + 'language_id', 'description', 'settings', 'is_active', @@ -60,12 +69,36 @@ public function getStatusAttribute(): string $hoursAgo = $this->last_fetched_at->diffInHours(now()); - if ($hoursAgo < 2) { + if ($hoursAgo < self::RECENT_FETCH_THRESHOLD_HOURS) { return 'Recently fetched'; - } elseif ($hoursAgo < 24) { + } elseif ($hoursAgo < self::DAILY_FETCH_THRESHOLD_HOURS) { return "Fetched {$hoursAgo}h ago"; } else { return "Fetched " . $this->last_fetched_at->diffForHumans(); } } + + public function channels(): BelongsToMany + { + return $this->belongsToMany(PlatformChannel::class, 'feed_platform_channels') + ->withPivot(['is_active', 'priority', 'filters']) + ->withTimestamps(); + } + + public function activeChannels(): BelongsToMany + { + return $this->channels() + ->wherePivot('is_active', true) + ->orderByPivot('priority', 'desc'); + } + + public function articles(): HasMany + { + return $this->hasMany(Article::class); + } + + public function language(): BelongsTo + { + return $this->belongsTo(Language::class); + } } diff --git a/app/Models/Language.php b/app/Models/Language.php new file mode 100644 index 0000000..ccbc850 --- /dev/null +++ b/app/Models/Language.php @@ -0,0 +1,38 @@ + 'boolean' + ]; + + public function platformInstances(): BelongsToMany + { + return $this->belongsToMany(PlatformInstance::class) + ->withPivot(['platform_language_id', 'is_default']) + ->withTimestamps(); + } + + public function platformChannels(): HasMany + { + return $this->hasMany(PlatformChannel::class); + } + + public function feeds(): HasMany + { + return $this->hasMany(Feed::class); + } +} diff --git a/app/Models/Log.php b/app/Models/Log.php index 4f3dea5..0d2dcd1 100644 --- a/app/Models/Log.php +++ b/app/Models/Log.php @@ -4,7 +4,16 @@ use App\LogLevelEnum; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Carbon; +/** + * @method static create(array $array) + * @property LogLevelEnum $level + * @property string $message + * @property array $context + * @property Carbon $created_at + * @property Carbon $updated_at + */ class Log extends Model { protected $table = 'logs'; diff --git a/app/Models/PlatformChannel.php b/app/Models/PlatformChannel.php index bfe7d50..b975f72 100644 --- a/app/Models/PlatformChannel.php +++ b/app/Models/PlatformChannel.php @@ -8,18 +8,27 @@ /** * @method static create(array $validated) + * @method static findMany(mixed $channel_ids) + * @property integer $id + * @property integer $platform_instance_id * @property PlatformInstance $platformInstance + * @property integer $channel_id + * @property string $name + * @property int $language_id + * @property Language $language + * @property boolean $is_active */ class PlatformChannel extends Model { protected $table = 'platform_channels'; - + protected $fillable = [ 'platform_instance_id', 'name', 'display_name', 'channel_id', 'description', + 'language_id', 'is_active' ]; @@ -45,4 +54,23 @@ public function getFullNameAttribute(): string $prefix = $this->platformInstance->platform === 'lemmy' ? '/c/' : '/'; return $this->platformInstance->url . $prefix . $this->name; } + + public function feeds(): BelongsToMany + { + return $this->belongsToMany(Feed::class, 'feed_platform_channels') + ->withPivot(['is_active', 'priority', 'filters']) + ->withTimestamps(); + } + + public function activeFeeds(): BelongsToMany + { + return $this->feeds() + ->wherePivot('is_active', true) + ->orderByPivot('priority', 'desc'); + } + + public function language(): BelongsTo + { + return $this->belongsTo(Language::class); + } } diff --git a/app/Models/PlatformInstance.php b/app/Models/PlatformInstance.php index 5acdd65..9d2c5a4 100644 --- a/app/Models/PlatformInstance.php +++ b/app/Models/PlatformInstance.php @@ -4,6 +4,7 @@ use App\Enums\PlatformEnum; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; /** @@ -30,9 +31,16 @@ class PlatformInstance extends Model 'is_active' => 'boolean' ]; - public function communities(): HasMany + public function channels(): HasMany { - return $this->hasMany(Community::class); + return $this->hasMany(PlatformChannel::class); + } + + public function languages(): BelongsToMany + { + return $this->belongsToMany(Language::class) + ->withPivot(['platform_language_id', 'is_default']) + ->withTimestamps(); } public static function findByUrl(PlatformEnum $platform, string $url): ?self diff --git a/app/Modules/Lemmy/Services/LemmyPublisher.php b/app/Modules/Lemmy/Services/LemmyPublisher.php index d9bfcd1..17dfdd3 100644 --- a/app/Modules/Lemmy/Services/LemmyPublisher.php +++ b/app/Modules/Lemmy/Services/LemmyPublisher.php @@ -2,17 +2,12 @@ namespace App\Modules\Lemmy\Services; -use App\Enums\PlatformEnum; use App\Exceptions\PlatformAuthException; -use App\Exceptions\PublishException; use App\Models\Article; -use App\Models\ArticlePublication; use App\Models\PlatformAccount; +use App\Models\PlatformChannel; use App\Services\Auth\LemmyAuthService; use Exception; -use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; -use RuntimeException; class LemmyPublisher { @@ -25,130 +20,26 @@ public function __construct(PlatformAccount $account) $this->account = $account; } - public static function fromActiveAccount(): self - { - $accounts = PlatformAccount::getActive(PlatformEnum::LEMMY); - - if ($accounts->isEmpty()) { - throw new RuntimeException('No active Lemmy accounts configured'); // TODO Also make this into a PublishException - } - - return new self($accounts->first()); - } - - /** - * @throws PublishException - */ - public function publish(Article $article, array $extractedData): Collection - { - $publications = collect(); - $activeChannels = $this->account->activeChannels; - - if ($activeChannels->isEmpty()) { - throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('No active channels configured for account: ' . $this->account->username)); - } - - $activeChannels->each(function ($channel) use ($article, $extractedData, $publications) { - try { - $publication = $this->publishToChannel($article, $extractedData, $channel); - $publications->push($publication); - } catch (Exception $e) { - logger()->warning('Failed to publish to channel', [ - 'article_id' => $article->id, - 'channel' => $channel->name, - 'error' => $e->getMessage() - ]); - } - }); - - if ($publications->isEmpty()) { - throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('Failed to publish to any channel')); - } - - return $publications; - } - /** * @throws PlatformAuthException * @throws Exception */ - private function publishToChannel(Article $article, array $extractedData, $channel): ArticlePublication + public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array { $token = LemmyAuthService::getToken($this->account); - $languageId = $this->getLanguageIdForSource($article->url); - $postData = $this->api->createPost( + // Use the language ID from extracted data (should be set during validation) + $languageId = $extractedData['language_id'] ?? null; + + return $this->api->createPost( $token, $extractedData['title'] ?? 'Untitled', $extractedData['description'] ?? '', - (int) $channel->channel_id, + $channel->channel_id, $article->url, $extractedData['thumbnail'] ?? null, $languageId ); - - return $this->createPublicationRecord($article, $postData, (int) $channel->channel_id); } - private function createPublicationRecord(Article $article, array $postData, int $channelId): ArticlePublication - { - return ArticlePublication::create([ - 'article_id' => $article->id, - 'post_id' => $postData['post_view']['post']['id'], - 'community_id' => $channelId, - 'published_by' => $this->account->username, - 'published_at' => now(), - 'platform' => 'lemmy', - 'publication_data' => $postData, - ]); - } - - private function getLanguageIdForSource(string $url): ?int - { - // TODO this will be obsolete when sources can be created from the UI, so we can remove these hard-coded sources - - // VRT articles are in Dutch - if (str_contains($url, 'vrt.be')) { - return $this->getLanguageId('nl'); // Dutch - } - - // Belga articles are in English (based on URL structure) - if (str_contains($url, 'belganewsagency.eu')) { - return $this->getLanguageId('en'); // English - } - - return null; // Default to no language specified - } - - private function getLanguageId(string $languageCode): ?int - { - $cacheKey = "lemmy_language_id_$languageCode"; - $cachedId = Cache::get($cacheKey); - - if ($cachedId !== null) { - return $cachedId; - } - - try { - $languages = $this->api->getLanguages(); - - foreach ($languages as $language) { - if (isset($language['code']) && $language['code'] === $languageCode) { - $languageId = $language['id']; - Cache::put($cacheKey, $languageId, 3600); - return $languageId; - } - } - - // Cache null result to avoid repeated API calls - Cache::put($cacheKey, null, 3600); - return null; - } catch (Exception $e) { - logger()->warning('Failed to get language ID', [ - 'language_code' => $languageCode, - 'error' => $e->getMessage() - ]); - return null; - } - } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 34bbef2..11aa3e2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,28 +2,24 @@ namespace App\Providers; -use App\Events\ArticleFetched; -use App\Events\ArticleReadyToPublish; use App\Events\ExceptionOccurred; -use App\Listeners\ValidateArticle; use App\Listeners\LogExceptionToDatabase; -use App\Listeners\PublishArticle; use App\LogLevelEnum; +use Error; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use InvalidArgumentException; use Throwable; class AppServiceProvider extends ServiceProvider { public function register(): void { - // } public function boot(): void { - Event::listen( ExceptionOccurred::class, LogExceptionToDatabase::class, @@ -40,9 +36,8 @@ public function boot(): void private function mapExceptionToLogLevel(Throwable $exception): LogLevelEnum { return match (true) { - $exception instanceof \Error => LogLevelEnum::CRITICAL, - $exception instanceof \RuntimeException => LogLevelEnum::ERROR, - $exception instanceof \InvalidArgumentException => LogLevelEnum::WARNING, + $exception instanceof Error => LogLevelEnum::CRITICAL, + $exception instanceof InvalidArgumentException => LogLevelEnum::WARNING, default => LogLevelEnum::ERROR, }; } diff --git a/app/Services/Article/ArticleFetcher.php b/app/Services/Article/ArticleFetcher.php index 19e08ef..6f95ab7 100644 --- a/app/Services/Article/ArticleFetcher.php +++ b/app/Services/Article/ArticleFetcher.php @@ -3,34 +3,68 @@ namespace App\Services\Article; use App\Models\Article; +use App\Models\Feed; use App\Services\Http\HttpFetcher; use App\Services\Factories\ArticleParserFactory; use App\Services\Factories\HomepageParserFactory; +use App\Services\Log\LogSaver; use Exception; use Illuminate\Support\Collection; class ArticleFetcher { - public static function getNewArticles(): Collection + public static function getArticlesFromFeed(Feed $feed): Collection + { + if ($feed->type === 'rss') { + return self::getArticlesFromRssFeed($feed); + } elseif ($feed->type === 'website') { + return self::getArticlesFromWebsiteFeed($feed); + } + + LogSaver::warning("Unsupported feed type", null, [ + 'feed_id' => $feed->id, + 'feed_type' => $feed->type + ]); + + return collect(); + } + + private static function getArticlesFromRssFeed(Feed $feed): Collection + { + // TODO: Implement RSS feed parsing + // For now, return empty collection + return collect(); + } + + private static function getArticlesFromWebsiteFeed(Feed $feed): Collection { try { - $allArticles = collect(); + // Try to get parser for this feed + $parser = HomepageParserFactory::getParserForFeed($feed); - foreach (HomepageParserFactory::getAllParsers() as $parser) { - $html = HttpFetcher::fetchHtml($parser->getHomepageUrl()); - $urls = $parser->extractArticleUrls($html); + if (! $parser) { + LogSaver::warning("No parser available for feed URL", null, [ + 'feed_id' => $feed->id, + 'feed_url' => $feed->url + ]); - $articles = collect($urls) - ->map(fn (string $url) => self::saveArticle($url)); - - $allArticles = $allArticles->merge($articles); + return collect(); } - return $allArticles->filter(); - } catch (Exception $e) { - logger()->error("Failed to get new articles", ['error' => $e->getMessage()]); + $html = HttpFetcher::fetchHtml($feed->url); + $urls = $parser->extractArticleUrls($html); - return new Collection([]); + return collect($urls) + ->map(fn (string $url) => self::saveArticle($url, $feed->id)); + + } catch (Exception $e) { + LogSaver::error("Failed to fetch articles from website feed", null, [ + 'feed_id' => $feed->id, + 'feed_url' => $feed->url, + 'error' => $e->getMessage() + ]); + + return collect(); } } @@ -42,7 +76,7 @@ public static function fetchArticleData(Article $article): array return $parser->extractData($html); } catch (Exception $e) { - logger()->error('Exception while fetching article data', [ + LogSaver::error('Exception while fetching article data', null, [ 'url' => $article->url, 'error' => $e->getMessage() ]); @@ -51,7 +85,7 @@ public static function fetchArticleData(Article $article): array } } - private static function saveArticle(string $url): Article + private static function saveArticle(string $url, ?int $feedId = null): Article { $existingArticle = Article::where('url', $url)->first(); @@ -59,6 +93,9 @@ private static function saveArticle(string $url): Article return $existingArticle; } - return Article::create(['url' => $url]); + return Article::create([ + 'url' => $url, + 'feed_id' => $feedId + ]); } } diff --git a/app/Services/Factories/HomepageParserFactory.php b/app/Services/Factories/HomepageParserFactory.php index f9d6ffc..e6c1477 100644 --- a/app/Services/Factories/HomepageParserFactory.php +++ b/app/Services/Factories/HomepageParserFactory.php @@ -3,6 +3,7 @@ namespace App\Services\Factories; use App\Contracts\HomepageParserInterface; +use App\Models\Feed; use App\Services\Parsers\VrtHomepageParserAdapter; use App\Services\Parsers\BelgaHomepageParserAdapter; use Exception; @@ -14,11 +15,14 @@ class HomepageParserFactory BelgaHomepageParserAdapter::class, ]; + /** + * @throws Exception + */ public static function getParser(string $url): HomepageParserInterface { foreach (self::$parsers as $parserClass) { $parser = new $parserClass(); - + if ($parser->canParse($url)) { return $parser; } @@ -27,23 +31,12 @@ public static function getParser(string $url): HomepageParserInterface throw new Exception("No homepage parser found for URL: {$url}"); } - public static function getAllParsers(): array + public static function getParserForFeed(Feed $feed): ?HomepageParserInterface { - return array_map(fn($parserClass) => new $parserClass(), self::$parsers); - } - - public static function getSupportedSources(): array - { - return array_map(function($parserClass) { - $parser = new $parserClass(); - return $parser->getSourceName(); - }, self::$parsers); - } - - public static function registerParser(string $parserClass): void - { - if (!in_array($parserClass, self::$parsers)) { - self::$parsers[] = $parserClass; + try { + return self::getParser($feed->url); + } catch (Exception) { + return null; } } -} \ No newline at end of file +} diff --git a/app/Services/Log/LogSaver.php b/app/Services/Log/LogSaver.php new file mode 100644 index 0000000..6e6b11d --- /dev/null +++ b/app/Services/Log/LogSaver.php @@ -0,0 +1,50 @@ + $channel->id, + 'channel_name' => $channel->name, + 'platform' => $channel->platformInstance->platform->value, + 'instance_url' => $channel->platformInstance->url, + ]); + } + + Log::create([ + 'level' => $level, + 'message' => $message, + 'context' => $logContext, + ]); + } +} \ No newline at end of file diff --git a/app/Services/Publishing/ArticlePublishingService.php b/app/Services/Publishing/ArticlePublishingService.php new file mode 100644 index 0000000..5d369aa --- /dev/null +++ b/app/Services/Publishing/ArticlePublishingService.php @@ -0,0 +1,76 @@ +is_valid) { + throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('CANNOT_PUBLISH_INVALID_ARTICLE')); + } + + $feed = $article->feed; + $activeChannels = $feed->activeChannels()->with(['platformInstance', 'platformAccounts'])->get(); + + return $activeChannels->map(function ($channel) use ($article, $extractedData) { + $account = $channel->platformAccounts()->where('is_active', true)->first(); + + if (! $account) { + LogSaver::warning('No active account for channel', $channel, [ + 'article_id' => $article->id + ]); + + return null; + } + + return $this->publishToChannel($article, $extractedData, $channel, $account); + }) + ->filter(); + } + + private function publishToChannel(Article $article, array $extractedData, $channel, $account): ?ArticlePublication + { + try { + $publisher = new LemmyPublisher($account); + $postData = $publisher->publishToChannel($article, $extractedData, $channel); + + $publication = ArticlePublication::create([ + 'article_id' => $article->id, + 'post_id' => $postData['post_view']['post']['id'], + 'community_id' => $channel->channel_id, + 'published_by' => $account->username, + 'published_at' => now(), + 'platform' => $channel->platformInstance->platform->value, + 'publication_data' => $postData, + ]); + + LogSaver::info('Published to channel via routing', $channel, [ + 'article_id' => $article->id, + 'priority' => $channel->pivot->priority + ]); + + return $publication; + } catch (Exception $e) { + LogSaver::warning('Failed to publish to channel', $channel, [ + 'article_id' => $article->id, + 'error' => $e->getMessage() + ]); + + return null; + } + } +} diff --git a/app/Services/RoutingValidationService.php b/app/Services/RoutingValidationService.php new file mode 100644 index 0000000..dddac21 --- /dev/null +++ b/app/Services/RoutingValidationService.php @@ -0,0 +1,30 @@ +language) { + return; + } + + foreach ($channels as $channel) { + if (! $channel->language) { + continue; + } + + if ($feed->language !== $channel->language) { + throw new RoutingMismatchException($feed, $channel); + } + } + } +} \ No newline at end of file diff --git a/config/lemmy.php b/config/lemmy.php deleted file mode 100644 index 23d71d0..0000000 --- a/config/lemmy.php +++ /dev/null @@ -1,9 +0,0 @@ - env('LEMMY_USERNAME'), - 'password' => env('LEMMY_PASSWORD'), - 'instance' => env('LEMMY_INSTANCE'), - 'community' => env('LEMMY_COMMUNITY'), - 'community_id' => env('LEMMY_COMMUNITY_ID'), -]; diff --git a/database/migrations/2025_07_05_005128_create_feed_platform_channels_table.php b/database/migrations/2025_07_05_005128_create_feed_platform_channels_table.php new file mode 100644 index 0000000..52af4bf --- /dev/null +++ b/database/migrations/2025_07_05_005128_create_feed_platform_channels_table.php @@ -0,0 +1,27 @@ +foreignId('feed_id')->constrained()->onDelete('cascade'); + $table->foreignId('platform_channel_id')->constrained()->onDelete('cascade'); + $table->boolean('is_active')->default(true); + $table->integer('priority')->default(0); // for ordering/priority + $table->json('filters')->nullable(); // keyword filters, content filters, etc. + $table->timestamps(); + + $table->primary(['feed_id', 'platform_channel_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('feed_platform_channels'); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_07_05_070153_add_feed_id_to_articles_table.php b/database/migrations/2025_07_05_070153_add_feed_id_to_articles_table.php new file mode 100644 index 0000000..9ff63bc --- /dev/null +++ b/database/migrations/2025_07_05_070153_add_feed_id_to_articles_table.php @@ -0,0 +1,25 @@ +foreignId('feed_id')->nullable()->constrained()->onDelete('cascade'); + $table->index(['feed_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->dropIndex(['feed_id', 'created_at']); + $table->dropForeign(['feed_id']); + $table->dropColumn('feed_id'); + }); + } +}; diff --git a/database/migrations/2025_07_05_124728_add_language_to_platform_channels_table.php b/database/migrations/2025_07_05_124728_add_language_to_platform_channels_table.php new file mode 100644 index 0000000..7202426 --- /dev/null +++ b/database/migrations/2025_07_05_124728_add_language_to_platform_channels_table.php @@ -0,0 +1,22 @@ +string('language', 2)->nullable()->after('description'); + }); + } + + public function down(): void + { + Schema::table('platform_channels', function (Blueprint $table) { + $table->dropColumn('language'); + }); + } +}; diff --git a/database/migrations/2025_07_05_142122_create_languages_table.php b/database/migrations/2025_07_05_142122_create_languages_table.php new file mode 100644 index 0000000..79e1f67 --- /dev/null +++ b/database/migrations/2025_07_05_142122_create_languages_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('short_code', 2)->unique(); // ISO 639-1 language code (en, fr, de, etc.) + $table->string('name'); // English name (English, French, German, etc.) + $table->string('native_name')->nullable(); // Native name (English, Français, Deutsch, etc.) + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('languages'); + } +}; diff --git a/database/migrations/2025_07_05_142443_create_language_platform_instance_table.php b/database/migrations/2025_07_05_142443_create_language_platform_instance_table.php new file mode 100644 index 0000000..e876d80 --- /dev/null +++ b/database/migrations/2025_07_05_142443_create_language_platform_instance_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('language_id')->constrained()->onDelete('cascade'); + $table->foreignId('platform_instance_id')->constrained()->onDelete('cascade'); + $table->integer('platform_language_id')->nullable(); // The platform-specific ID (e.g., Lemmy's language ID) + $table->boolean('is_default')->default(false); // Whether this is the default language for this instance + $table->timestamps(); + + $table->unique(['language_id', 'platform_instance_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('language_platform_instance'); + } +}; diff --git a/database/migrations/2025_07_05_142644_update_feeds_table_use_language_id.php b/database/migrations/2025_07_05_142644_update_feeds_table_use_language_id.php new file mode 100644 index 0000000..0654ea3 --- /dev/null +++ b/database/migrations/2025_07_05_142644_update_feeds_table_use_language_id.php @@ -0,0 +1,25 @@ +dropColumn('language'); + $table->foreignId('language_id')->nullable()->constrained(); + }); + } + + public function down(): void + { + Schema::table('feeds', function (Blueprint $table) { + $table->dropForeign(['language_id']); + $table->dropColumn('language_id'); + $table->string('language', 2)->nullable(); + }); + } +}; diff --git a/database/migrations/2025_07_05_142807_update_platform_channels_table_use_language_id.php b/database/migrations/2025_07_05_142807_update_platform_channels_table_use_language_id.php new file mode 100644 index 0000000..fe75ca0 --- /dev/null +++ b/database/migrations/2025_07_05_142807_update_platform_channels_table_use_language_id.php @@ -0,0 +1,25 @@ +dropColumn('language'); + $table->foreignId('language_id')->nullable()->constrained(); + }); + } + + public function down(): void + { + Schema::table('platform_channels', function (Blueprint $table) { + $table->dropForeign(['language_id']); + $table->dropColumn('language_id'); + $table->string('language', 2)->nullable(); + }); + } +}; diff --git a/resources/views/pages/routing/create.blade.php b/resources/views/pages/routing/create.blade.php new file mode 100644 index 0000000..f415e51 --- /dev/null +++ b/resources/views/pages/routing/create.blade.php @@ -0,0 +1,127 @@ +@extends('layouts.app') + +@section('content') +
Route a feed to one or more channels.
++ {{ $feed->name }} → {{ $channel->name }} ({{ $channel->platformInstance->name }}) +
+Manage how feeds are routed to channels
+Active feed-to-channel routing
+Channels and their connected feeds
+Connect your feeds to channels to start routing content.
+ + Create First Routing + +