Routing CRUD

This commit is contained in:
myrmidex 2025-07-05 18:26:04 +02:00
parent c7302092bb
commit bea1e67b19
43 changed files with 1333 additions and 377 deletions

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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)
{
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class ChannelException extends Exception
{
}

View file

@ -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;
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class RoutingException extends Exception
{
}

View 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);
}
}

View 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;
}
}

View 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
]);
});
}
}

View 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');
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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));
});
}
}

View file

@ -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
View 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);
}
}

View file

@ -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';

View file

@ -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);
}
}

View file

@ -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

View file

@ -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;
}
}
}

View file

@ -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,
};
}

View file

@ -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
]);
}
}

View file

@ -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;
}
}
}
}

View 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,
]);
}
}

View 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;
}
}
}

View 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);
}
}
}
}

View file

@ -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'),
];

View file

@ -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');
}
};

View file

@ -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');
});
}
};

View file

@ -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');
});
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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();
});
}
};

View file

@ -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();
});
}
};

View 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

View 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

View 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

View file

@ -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

View file

@ -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');

View file

@ -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');