Routing CRUD
This commit is contained in:
parent
c7302092bb
commit
bea1e67b19
43 changed files with 1333 additions and 377 deletions
|
|
@ -1,27 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class FetchArticleCommand extends Command
|
||||
{
|
||||
protected $signature = 'article:fetch {url}';
|
||||
|
||||
protected $description = 'Fetch article from url';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$article = Article::createQuietly([
|
||||
'url' => $this->argument('url'),
|
||||
]);
|
||||
|
||||
$res = ArticleFetcher::fetchArticleData($article);
|
||||
|
||||
dump($res);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
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
|
||||
|
|
@ -22,39 +20,15 @@ public function handle(): int
|
|||
}
|
||||
|
||||
$this->error("Unsupported platform: {$platform}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
private function syncLemmy(): int
|
||||
{
|
||||
$communityName = config('lemmy.community');
|
||||
SyncChannelPostsJob::dispatchForAllActiveChannels();
|
||||
$this->info('Successfully dispatched sync jobs for all active Lemmy channels');
|
||||
|
||||
if (!$communityName) {
|
||||
$this->error('Missing Lemmy community configuration (lemmy.community)');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
9
app/Exceptions/ChannelException.php
Normal file
9
app/Exceptions/ChannelException.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class ChannelException extends Exception
|
||||
{
|
||||
}
|
||||
|
|
@ -11,10 +11,14 @@ class PublishException extends Exception
|
|||
{
|
||||
public function __construct(
|
||||
private readonly Article $article,
|
||||
private readonly PlatformEnum $platform,
|
||||
private readonly PlatformEnum|null $platform,
|
||||
?Throwable $previous = null
|
||||
) {
|
||||
$message = "Failed to publish article #{$article->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;
|
||||
}
|
||||
|
|
|
|||
9
app/Exceptions/RoutingException.php
Normal file
9
app/Exceptions/RoutingException.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class RoutingException extends Exception
|
||||
{
|
||||
}
|
||||
22
app/Exceptions/RoutingMismatchException.php
Normal file
22
app/Exceptions/RoutingMismatchException.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Models\PlatformChannel;
|
||||
|
||||
class RoutingMismatchException extends RoutingException
|
||||
{
|
||||
public function __construct(Feed $feed, PlatformChannel $channel)
|
||||
{
|
||||
$message = sprintf(
|
||||
"Language mismatch: Feed '%s' is in '%s' but channel '%s' is set to '%s'. Feed and channel languages must match for proper content routing.",
|
||||
$feed->name,
|
||||
$feed->language,
|
||||
$channel->name,
|
||||
$channel->language
|
||||
);
|
||||
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
165
app/Http/Controllers/RoutingController.php
Normal file
165
app/Http/Controllers/RoutingController.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Services\RoutingValidationService;
|
||||
use App\Exceptions\RoutingMismatchException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class RoutingController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$feeds = Feed::with(['channels.platformInstance'])
|
||||
->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;
|
||||
}
|
||||
}
|
||||
61
app/Jobs/ArticleDiscoveryForFeedJob.php
Normal file
61
app/Jobs/ArticleDiscoveryForFeedJob.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class ArticleDiscoveryForFeedJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
private const FEED_DISCOVERY_DELAY_MINUTES = 5;
|
||||
|
||||
public function __construct(
|
||||
private readonly Feed $feed
|
||||
) {
|
||||
$this->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
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
26
app/Jobs/ArticleDiscoveryJob.php
Normal file
26
app/Jobs/ArticleDiscoveryJob.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\Log\LogSaver;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class ArticleDiscoveryJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class RefreshArticlesJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('lemmy-posts');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
ArticleFetcher::getNewArticles();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,72 +4,75 @@
|
|||
|
||||
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";
|
||||
LogSaver::info('Starting channel posts sync job', $this->channel);
|
||||
|
||||
if ($this->platform === PlatformEnum::LEMMY) {
|
||||
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()
|
||||
]);
|
||||
|
||||
|
|
@ -77,28 +80,29 @@ private function syncLemmyChannelPosts(): void
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
38
app/Models/Language.php
Normal file
38
app/Models/Language.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Language extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'short_code',
|
||||
'name',
|
||||
'native_name',
|
||||
'is_active'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => '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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -8,7 +8,15 @@
|
|||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
|
|
@ -20,6 +28,7 @@ class PlatformChannel extends Model
|
|||
'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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +15,9 @@ class HomepageParserFactory
|
|||
BelgaHomepageParserAdapter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function getParser(string $url): HomepageParserInterface
|
||||
{
|
||||
foreach (self::$parsers as $parserClass) {
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
app/Services/Log/LogSaver.php
Normal file
50
app/Services/Log/LogSaver.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Log;
|
||||
|
||||
use App\LogLevelEnum;
|
||||
use App\Models\Log;
|
||||
use App\Models\PlatformChannel;
|
||||
|
||||
class LogSaver
|
||||
{
|
||||
public static function info(string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||
{
|
||||
self::log(LogLevelEnum::INFO, $message, $channel, $context);
|
||||
}
|
||||
|
||||
public static function error(string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||
{
|
||||
self::log(LogLevelEnum::ERROR, $message, $channel, $context);
|
||||
}
|
||||
|
||||
public static function warning(string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||
{
|
||||
self::log(LogLevelEnum::WARNING, $message, $channel, $context);
|
||||
}
|
||||
|
||||
public static function debug(string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||
{
|
||||
self::log(LogLevelEnum::DEBUG, $message, $channel, $context);
|
||||
}
|
||||
|
||||
private static function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||
{
|
||||
$logContext = $context;
|
||||
|
||||
if ($channel) {
|
||||
$logContext = array_merge($logContext, [
|
||||
'channel_id' => $channel->id,
|
||||
'channel_name' => $channel->name,
|
||||
'platform' => $channel->platformInstance->platform->value,
|
||||
'instance_url' => $channel->platformInstance->url,
|
||||
]);
|
||||
}
|
||||
|
||||
Log::create([
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
'context' => $logContext,
|
||||
]);
|
||||
}
|
||||
}
|
||||
76
app/Services/Publishing/ArticlePublishingService.php
Normal file
76
app/Services/Publishing/ArticlePublishingService.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Publishing;
|
||||
|
||||
use App\Enums\PlatformEnum;
|
||||
use App\Exceptions\PublishException;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Exception;
|
||||
use Illuminate\Support\Collection;
|
||||
use RuntimeException;
|
||||
|
||||
class ArticlePublishingService
|
||||
{
|
||||
/**
|
||||
* @throws PublishException
|
||||
*/
|
||||
public function publishToRoutedChannels(Article $article, array $extractedData): Collection
|
||||
{
|
||||
if (! $article->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/Services/RoutingValidationService.php
Normal file
30
app/Services/RoutingValidationService.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RoutingMismatchException;
|
||||
use App\Models\Feed;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class RoutingValidationService
|
||||
{
|
||||
/**
|
||||
* @throws RoutingMismatchException
|
||||
*/
|
||||
public function validateLanguageCompatibility(Feed $feed, Collection $channels): void
|
||||
{
|
||||
if (! $feed->language) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($channels as $channel) {
|
||||
if (! $channel->language) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($feed->language !== $channel->language) {
|
||||
throw new RoutingMismatchException($feed, $channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'username' => env('LEMMY_USERNAME'),
|
||||
'password' => env('LEMMY_PASSWORD'),
|
||||
'instance' => env('LEMMY_INSTANCE'),
|
||||
'community' => env('LEMMY_COMMUNITY'),
|
||||
'community_id' => env('LEMMY_COMMUNITY_ID'),
|
||||
];
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('feed_platform_channels', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('articles', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->string('language', 2)->nullable()->after('description');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->dropColumn('language');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('languages', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('language_platform_instance', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('feeds', function (Blueprint $table) {
|
||||
$table->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
127
resources/views/pages/routing/create.blade.php
Normal file
127
resources/views/pages/routing/create.blade.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-2xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Create Feed Routing</h1>
|
||||
<p class="mt-1 text-sm text-gray-600">Route a feed to one or more channels.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<form action="{{ route('routing.store') }}" method="POST" class="px-4 py-5 sm:p-6">
|
||||
@csrf
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<label for="feed_id" class="block text-sm font-medium text-gray-700">Feed</label>
|
||||
<select name="feed_id"
|
||||
id="feed_id"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('feed_id') border-red-300 @enderror">
|
||||
<option value="">Select a feed...</option>
|
||||
@foreach($feeds as $feed)
|
||||
<option value="{{ $feed->id }}" {{ old('feed_id') == $feed->id ? 'selected' : '' }}>
|
||||
{{ $feed->name }} ({{ $feed->type_display }})
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('feed_id')
|
||||
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">Target Channels</label>
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-md p-3">
|
||||
@forelse($channels as $channel)
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="channel_ids[]"
|
||||
value="{{ $channel->id }}"
|
||||
id="channel_{{ $channel->id }}"
|
||||
{{ in_array($channel->id, old('channel_ids', [])) ? 'checked' : '' }}
|
||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
|
||||
<label for="channel_{{ $channel->id }}" class="ml-2 block text-sm text-gray-900">
|
||||
<span class="font-medium">{{ $channel->name }}</span>
|
||||
<span class="text-gray-500">({{ $channel->platformInstance->name }})</span>
|
||||
</label>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-gray-500">No active channels available. Please create channels first.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@error('channel_ids')
|
||||
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
@error('channel_ids.*')
|
||||
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="priority" class="block text-sm font-medium text-gray-700">Priority</label>
|
||||
<input type="number"
|
||||
name="priority"
|
||||
id="priority"
|
||||
min="0"
|
||||
max="100"
|
||||
value="{{ old('priority', 0) }}"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('priority') border-red-300 @enderror"
|
||||
placeholder="0">
|
||||
<p class="mt-1 text-sm text-gray-500">Higher numbers = higher priority (0-100)</p>
|
||||
@error('priority')
|
||||
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="filters" class="block text-sm font-medium text-gray-700">Filters (Optional)</label>
|
||||
<textarea name="filters"
|
||||
id="filters"
|
||||
rows="4"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('filters') border-red-300 @enderror"
|
||||
placeholder='{"keywords": ["technology", "AI"], "exclude_keywords": ["sports"]}'>{{ old('filters') }}</textarea>
|
||||
<p class="mt-1 text-sm text-gray-500">JSON format for content filtering rules</p>
|
||||
@error('filters')
|
||||
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end space-x-3">
|
||||
<a href="{{ route('routing.index') }}" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" class="bg-indigo-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Create Routing
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if($feeds->isEmpty() || $channels->isEmpty())
|
||||
<div class="mt-6 bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Prerequisites Missing</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>To create routing, you need:</p>
|
||||
<ul class="list-disc list-inside mt-1">
|
||||
@if($feeds->isEmpty())
|
||||
<li>At least one active <a href="{{ route('feeds.create') }}" class="underline">feed</a></li>
|
||||
@endif
|
||||
@if($channels->isEmpty())
|
||||
<li>At least one active <a href="{{ route('channels.create') }}" class="underline">channel</a></li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
131
resources/views/pages/routing/edit.blade.php
Normal file
131
resources/views/pages/routing/edit.blade.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-2xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Edit Routing</h1>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{{ $feed->name }} → {{ $channel->name }} ({{ $channel->platformInstance->name }})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<form action="{{ route('routing.update', [$feed, $channel]) }}" method="POST" class="px-4 py-5 sm:p-6">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Routing Details</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">Feed:</span>
|
||||
<div class="flex items-center mt-1">
|
||||
@if($feed->type === 'rss')
|
||||
<i class="fas fa-rss text-orange-500 mr-2"></i>
|
||||
@else
|
||||
<i class="fas fa-globe text-blue-500 mr-2"></i>
|
||||
@endif
|
||||
{{ $feed->name }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">Channel:</span>
|
||||
<div class="flex items-center mt-1">
|
||||
<i class="fas fa-hashtag text-gray-400 mr-2"></i>
|
||||
{{ $channel->name }}
|
||||
<span class="ml-2 text-gray-500">({{ $channel->platformInstance->name }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="is_active"
|
||||
id="is_active"
|
||||
value="1"
|
||||
{{ old('is_active', $routing->pivot->is_active) ? 'checked' : '' }}
|
||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-900">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="priority" class="block text-sm font-medium text-gray-700">Priority</label>
|
||||
<input type="number"
|
||||
name="priority"
|
||||
id="priority"
|
||||
min="0"
|
||||
max="100"
|
||||
value="{{ old('priority', $routing->pivot->priority) }}"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('priority') border-red-300 @enderror"
|
||||
placeholder="0">
|
||||
<p class="mt-1 text-sm text-gray-500">Higher numbers = higher priority (0-100)</p>
|
||||
@error('priority')
|
||||
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="filters" class="block text-sm font-medium text-gray-700">Filters</label>
|
||||
<textarea name="filters"
|
||||
id="filters"
|
||||
rows="6"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('filters') border-red-300 @enderror"
|
||||
placeholder='{"keywords": ["technology", "AI"], "exclude_keywords": ["sports"]}'>{{ old('filters', $routing->pivot->filters ? json_encode($routing->pivot->filters, JSON_PRETTY_PRINT) : '') }}</textarea>
|
||||
<p class="mt-1 text-sm text-gray-500">JSON format for content filtering rules</p>
|
||||
@error('filters')
|
||||
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Filter Examples</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>You can use filters to control which content gets routed:</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li><code>{"keywords": ["tech", "AI"]}</code> - Include only articles with these keywords</li>
|
||||
<li><code>{"exclude_keywords": ["sports"]}</code> - Exclude articles with these keywords</li>
|
||||
<li><code>{"min_length": 500}</code> - Minimum article length</li>
|
||||
<li><code>{"max_age_hours": 24}</code> - Only articles from last 24 hours</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<form action="{{ route('routing.destroy', [$feed, $channel]) }}" method="POST" class="inline"
|
||||
onsubmit="return confirm('Are you sure you want to delete this routing?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="bg-red-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-red-700">
|
||||
Delete Routing
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<a href="{{ route('routing.index') }}" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" class="bg-indigo-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Update Routing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
151
resources/views/pages/routing/index.blade.php
Normal file
151
resources/views/pages/routing/index.blade.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Feed Routing</h1>
|
||||
<p class="mt-1 text-sm text-gray-600">Manage how feeds are routed to channels</p>
|
||||
</div>
|
||||
<a href="{{ route('routing.create') }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Create New Routing
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Feeds Section -->
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Feeds → Channels</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">Active feed-to-channel routing</p>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200">
|
||||
@forelse($feeds as $feed)
|
||||
@if($feed->channels->count() > 0)
|
||||
<div class="px-4 py-4">
|
||||
<div class="flex items-center mb-3">
|
||||
@if($feed->type === 'rss')
|
||||
<i class="fas fa-rss text-orange-500 mr-2"></i>
|
||||
@else
|
||||
<i class="fas fa-globe text-blue-500 mr-2"></i>
|
||||
@endif
|
||||
<span class="font-medium text-gray-900">{{ $feed->name }}</span>
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ $feed->channels->count() }} channel{{ $feed->channels->count() !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@foreach($feed->channels as $channel)
|
||||
<div class="flex items-center justify-between bg-gray-50 rounded px-3 py-2">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-hashtag text-gray-400 mr-2"></i>
|
||||
<span class="text-sm text-gray-900">{{ $channel->name }}</span>
|
||||
<span class="ml-2 text-xs text-gray-500">({{ $channel->platformInstance->name }})</span>
|
||||
@if(!$channel->pivot->is_active)
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
Inactive
|
||||
</span>
|
||||
@endif
|
||||
@if($channel->pivot->priority > 0)
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Priority: {{ $channel->pivot->priority }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<form action="{{ route('routing.toggle', [$feed, $channel]) }}" method="POST" class="inline">
|
||||
@csrf
|
||||
<button type="submit" class="text-xs px-2 py-1 rounded {{ $channel->pivot->is_active ? 'bg-red-100 text-red-800 hover:bg-red-200' : 'bg-green-100 text-green-800 hover:bg-green-200' }}">
|
||||
{{ $channel->pivot->is_active ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ route('routing.edit', [$feed, $channel]) }}" class="text-indigo-600 hover:text-indigo-900 text-xs">Edit</a>
|
||||
<form action="{{ route('routing.destroy', [$feed, $channel]) }}" method="POST" class="inline"
|
||||
onsubmit="return confirm('Remove this routing?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-600 hover:text-red-900 text-xs">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@empty
|
||||
<div class="px-4 py-8 text-center text-gray-500">
|
||||
No feed routing configured
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channels Section -->
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Channels ← Feeds</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">Channels and their connected feeds</p>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200">
|
||||
@forelse($channels as $channel)
|
||||
@if($channel->feeds->count() > 0)
|
||||
<div class="px-4 py-4">
|
||||
<div class="flex items-center mb-3">
|
||||
<i class="fas fa-hashtag text-gray-400 mr-2"></i>
|
||||
<span class="font-medium text-gray-900">{{ $channel->name }}</span>
|
||||
<span class="ml-2 text-xs text-gray-500">({{ $channel->platformInstance->name }})</span>
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
{{ $channel->feeds->count() }} feed{{ $channel->feeds->count() !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
@foreach($channel->feeds as $feed)
|
||||
<div class="flex items-center justify-between bg-gray-50 rounded px-3 py-2">
|
||||
<div class="flex items-center">
|
||||
@if($feed->type === 'rss')
|
||||
<i class="fas fa-rss text-orange-500 mr-2"></i>
|
||||
@else
|
||||
<i class="fas fa-globe text-blue-500 mr-2"></i>
|
||||
@endif
|
||||
<span class="text-sm text-gray-900">{{ $feed->name }}</span>
|
||||
@if(!$feed->pivot->is_active)
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
Inactive
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<a href="{{ route('routing.edit', [$feed, $channel]) }}" class="text-indigo-600 hover:text-indigo-900 text-xs">Edit</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@empty
|
||||
<div class="px-4 py-8 text-center text-gray-500">
|
||||
No channels with feeds
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($feeds->where('channels')->isEmpty() && $channels->where('feeds')->isEmpty())
|
||||
<div class="text-center py-12">
|
||||
<i class="fas fa-route text-gray-400 text-6xl mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No routing configured</h3>
|
||||
<p class="text-gray-500 mb-4">Connect your feeds to channels to start routing content.</p>
|
||||
<a href="{{ route('routing.create') }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Create First Routing
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -21,6 +21,10 @@
|
|||
<i class="fas fa-rss mr-3"></i>
|
||||
Feeds
|
||||
</a>
|
||||
<a href="/routing" class="flex items-center px-4 py-3 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors {{ request()->is('routing*') ? 'bg-gray-700 text-white' : '' }}">
|
||||
<i class="fas fa-route mr-3"></i>
|
||||
Routing
|
||||
</a>
|
||||
<a href="/logs" class="flex items-center px-4 py-3 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors {{ request()->is('logs') ? 'bg-gray-700 text-white' : '' }}">
|
||||
<i class="fas fa-list mr-3"></i>
|
||||
Logs
|
||||
|
|
|
|||
|
|
@ -1,29 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\FetchNewArticlesCommand;
|
||||
use App\Enums\PlatformEnum;
|
||||
use App\Jobs\SyncChannelPostsJob;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command(FetchNewArticlesCommand::class)->hourly();
|
||||
|
||||
Schedule::call(function () {
|
||||
$communityId = config('lemmy.community_id');
|
||||
$communityName = config('lemmy.community');
|
||||
|
||||
if ($communityId && $communityName) {
|
||||
SyncChannelPostsJob::dispatch(
|
||||
PlatformEnum::LEMMY,
|
||||
$communityId,
|
||||
$communityName
|
||||
);
|
||||
|
||||
logger()->info('Dispatched channel posts sync job', [
|
||||
'platform' => 'lemmy',
|
||||
'community_id' => $communityId,
|
||||
'community_name' => $communityName
|
||||
]);
|
||||
} else {
|
||||
logger()->warning('Missing Lemmy community configuration for sync job');
|
||||
}
|
||||
SyncChannelPostsJob::dispatchForAllActiveChannels();
|
||||
})->everyTenMinutes()->name('sync-lemmy-channel-posts');
|
||||
|
|
|
|||
|
|
@ -26,3 +26,11 @@
|
|||
|
||||
Route::resource('channels', App\Http\Controllers\PlatformChannelsController::class)->names('channels');
|
||||
Route::resource('feeds', App\Http\Controllers\FeedsController::class)->names('feeds');
|
||||
|
||||
Route::get('/routing', [App\Http\Controllers\RoutingController::class, 'index'])->name('routing.index');
|
||||
Route::get('/routing/create', [App\Http\Controllers\RoutingController::class, 'create'])->name('routing.create');
|
||||
Route::post('/routing', [App\Http\Controllers\RoutingController::class, 'store'])->name('routing.store');
|
||||
Route::get('/routing/{feed}/{channel}/edit', [App\Http\Controllers\RoutingController::class, 'edit'])->name('routing.edit');
|
||||
Route::put('/routing/{feed}/{channel}', [App\Http\Controllers\RoutingController::class, 'update'])->name('routing.update');
|
||||
Route::delete('/routing/{feed}/{channel}', [App\Http\Controllers\RoutingController::class, 'destroy'])->name('routing.destroy');
|
||||
Route::post('/routing/{feed}/{channel}/toggle', [App\Http\Controllers\RoutingController::class, 'toggle'])->name('routing.toggle');
|
||||
|
|
|
|||
Loading…
Reference in a new issue