Release v1.2.0 #87

Merged
myrmidex merged 9 commits from release/v1.2.0 into main 2026-03-08 21:30:58 +01:00
176 changed files with 1989 additions and 1437 deletions

113
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,113 @@
name: CI
on:
push:
branches: ['release/*']
pull_request:
branches: [main]
jobs:
ci:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up PHP
uses: https://github.com/shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_sqlite, mbstring, xml, dom
coverage: pcov
- name: Cache Composer dependencies
uses: https://data.forgejo.org/actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Prepare environment
run: cp .env.testing .env
- name: Lint
run: vendor/bin/pint --test
- name: Static analysis
run: vendor/bin/phpstan analyse
- name: Tests
run: php artisan test --coverage-clover coverage.xml --coverage-text
- name: Parse coverage
if: github.event_name == 'pull_request'
id: coverage
run: |
COVERAGE=$(php -r '
$xml = simplexml_load_file("coverage.xml");
if ($xml === false || !isset($xml->project->metrics)) {
echo "0";
exit;
}
$metrics = $xml->project->metrics;
$statements = (int) $metrics["statements"];
$covered = (int) $metrics["coveredstatements"];
echo $statements > 0 ? round(($covered / $statements) * 100, 2) : 0;
')
echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"
- name: Comment coverage on PR
if: github.event_name == 'pull_request'
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
COVERAGE: ${{ steps.coverage.outputs.percentage }}
REPO: ${{ github.repository }}
SERVER_URL: ${{ github.server_url }}
COMMIT_SHA: ${{ github.sha }}
run: |
API_URL="${SERVER_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments"
MARKER="<!-- ffr-ci-coverage-report -->"
BODY="${MARKER}
## Code Coverage Report
| Metric | Value |
|--------|-------|
| **Line Coverage** | ${COVERAGE}% |
_Updated by CI — commit ${COMMIT_SHA}_"
# Find existing coverage comment
EXISTING=$(curl -sf -H "Authorization: token ${FORGEJO_TOKEN}" \
"${API_URL}?limit=50" | \
php -r '
$comments = json_decode(file_get_contents("php://stdin"), true);
if (!is_array($comments)) exit;
foreach ($comments as $c) {
if (str_contains($c["body"], "<!-- ffr-ci-coverage-report -->")) {
echo $c["id"];
exit;
}
}
' || true)
if [ -n "$EXISTING" ]; then
# Update existing comment
curl -sf -X PATCH \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
"${SERVER_URL}/api/v1/repos/${REPO}/issues/comments/${EXISTING}" > /dev/null
else
# Create new comment
curl -sf -X POST \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
"${API_URL}" > /dev/null
fi

View file

@ -15,7 +15,7 @@ public function execute(string $name, string $provider, int $languageId, ?string
$url = config("feed.providers.{$provider}.languages.{$langCode}.url"); $url = config("feed.providers.{$provider}.languages.{$langCode}.url");
if (!$url) { if (! $url) {
throw new InvalidArgumentException("Invalid provider and language combination: {$provider}/{$langCode}"); throw new InvalidArgumentException("Invalid provider and language combination: {$provider}/{$langCode}");
} }

View file

@ -19,7 +19,7 @@ public function __construct(
*/ */
public function execute(string $instanceDomain, string $username, string $password, string $platform = 'lemmy'): PlatformAccount public function execute(string $instanceDomain, string $username, string $password, string $platform = 'lemmy'): PlatformAccount
{ {
$fullInstanceUrl = 'https://' . $instanceDomain; $fullInstanceUrl = 'https://'.$instanceDomain;
// Authenticate first — if this fails, no records are created // Authenticate first — if this fails, no records are created
$authResponse = $this->lemmyAuthService->authenticate($fullInstanceUrl, $username, $password); $authResponse = $this->lemmyAuthService->authenticate($fullInstanceUrl, $username, $password);

View file

@ -15,13 +15,13 @@ class FetchNewArticlesCommand extends Command
public function handle(): int public function handle(): int
{ {
if (!Setting::isArticleProcessingEnabled()) { if (! Setting::isArticleProcessingEnabled()) {
$this->info('Article processing is disabled. Article discovery skipped.'); $this->info('Article processing is disabled. Article discovery skipped.');
return self::SUCCESS; return self::SUCCESS;
} }
if (!Feed::where('is_active', true)->exists()) { if (! Feed::where('is_active', true)->exists()) {
$this->info('No active feeds found. Article discovery skipped.'); $this->info('No active feeds found. Article discovery skipped.');
return self::SUCCESS; return self::SUCCESS;

View file

@ -11,6 +11,7 @@ public function canParse(string $url): bool;
/** /**
* Extract article data from HTML * Extract article data from HTML
*
* @return array<string, mixed> * @return array<string, mixed>
*/ */
public function extractData(string $html): array; public function extractData(string $html): array;

View file

@ -11,6 +11,7 @@ public function canParse(string $url): bool;
/** /**
* Extract article URLs from homepage HTML * Extract article URLs from homepage HTML
*
* @return array<int, string> * @return array<int, string>
*/ */
public function extractArticleUrls(string $html): array; public function extractArticleUrls(string $html): array;

View file

@ -5,4 +5,18 @@
enum PlatformEnum: string enum PlatformEnum: string
{ {
case LEMMY = 'lemmy'; case LEMMY = 'lemmy';
public function channelLabel(): string
{
return match ($this) {
self::LEMMY => 'Community',
};
}
public function channelLabelPlural(): string
{
return match ($this) {
self::LEMMY => 'Communities',
};
}
} }

View file

@ -0,0 +1,18 @@
<?php
namespace App\Events;
use App\Enums\LogLevelEnum;
use Illuminate\Foundation\Events\Dispatchable;
class ActionPerformed
{
use Dispatchable;
public function __construct(
public string $message,
public LogLevelEnum $level = LogLevelEnum::INFO,
/** @var array<string, mixed> */
public array $context = [],
) {}
}

View file

@ -12,6 +12,5 @@ class ExceptionLogged
public function __construct( public function __construct(
public Log $log public Log $log
) { ) {}
}
} }

View file

@ -17,6 +17,5 @@ public function __construct(
public string $message, public string $message,
/** @var array<string, mixed> */ /** @var array<string, mixed> */
public array $context = [] public array $context = []
) { ) {}
}
} }

View file

@ -11,7 +11,5 @@ class NewArticleFetched
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Article $article) public function __construct(public Article $article) {}
{
}
} }

View file

@ -4,6 +4,4 @@
use Exception; use Exception;
class ChannelException extends Exception class ChannelException extends Exception {}
{
}

View file

@ -11,7 +11,7 @@ class PublishException extends Exception
{ {
public function __construct( public function __construct(
private readonly Article $article, private readonly Article $article,
private readonly PlatformEnum|null $platform, private readonly ?PlatformEnum $platform,
?Throwable $previous = null ?Throwable $previous = null
) { ) {
$message = "Failed to publish article #$article->id"; $message = "Failed to publish article #$article->id";

View file

@ -4,6 +4,4 @@
use Exception; use Exception;
class RoutingException extends Exception class RoutingException extends Exception {}
{
}

View file

@ -3,13 +3,12 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\ArticleResource; use App\Http\Resources\ArticleResource;
use App\Jobs\ArticleDiscoveryJob;
use App\Models\Article; use App\Models\Article;
use App\Models\Setting; use App\Models\Setting;
use App\Jobs\ArticleDiscoveryJob;
use Exception; use Exception;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
class ArticlesController extends BaseController class ArticlesController extends BaseController
{ {
@ -54,7 +53,7 @@ public function approve(Article $article): JsonResponse
'Article approved and queued for publishing.' 'Article approved and queued for publishing.'
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to approve article: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to approve article: '.$e->getMessage(), [], 500);
} }
} }
@ -71,7 +70,7 @@ public function reject(Article $article): JsonResponse
'Article rejected.' 'Article rejected.'
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to reject article: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to reject article: '.$e->getMessage(), [], 500);
} }
} }
@ -88,7 +87,7 @@ public function refresh(): JsonResponse
'Article refresh started. New articles will appear shortly.' 'Article refresh started. New articles will appear shortly.'
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to start article refresh: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to start article refresh: '.$e->getMessage(), [], 500);
} }
} }
} }

View file

@ -5,7 +5,6 @@
use App\Models\User; use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -24,7 +23,7 @@ public function login(Request $request): JsonResponse
$user = User::where('email', $request->email)->first(); $user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) { if (! $user || ! Hash::check($request->password, $user->password)) {
return $this->sendError('Invalid credentials', [], 401); return $this->sendError('Invalid credentials', [], 401);
} }
@ -42,7 +41,7 @@ public function login(Request $request): JsonResponse
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->sendValidationError($e->errors()); return $this->sendValidationError($e->errors());
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->sendError('Login failed: ' . $e->getMessage(), [], 500); return $this->sendError('Login failed: '.$e->getMessage(), [], 500);
} }
} }
@ -78,7 +77,7 @@ public function register(Request $request): JsonResponse
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->sendValidationError($e->errors()); return $this->sendValidationError($e->errors());
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->sendError('Registration failed: ' . $e->getMessage(), [], 500); return $this->sendError('Registration failed: '.$e->getMessage(), [], 500);
} }
} }
@ -92,7 +91,7 @@ public function logout(Request $request): JsonResponse
return $this->sendResponse(null, 'Logged out successfully'); return $this->sendResponse(null, 'Logged out successfully');
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->sendError('Logout failed: ' . $e->getMessage(), [], 500); return $this->sendError('Logout failed: '.$e->getMessage(), [], 500);
} }
} }

View file

@ -23,6 +23,8 @@ public function sendResponse(mixed $result, string $message = 'Success', int $co
/** /**
* Error response method * Error response method
*
* @param array<string, mixed> $errorMessages
*/ */
public function sendError(string $error, array $errorMessages = [], int $code = 400): JsonResponse public function sendError(string $error, array $errorMessages = [], int $code = 400): JsonResponse
{ {
@ -31,7 +33,7 @@ public function sendError(string $error, array $errorMessages = [], int $code =
'message' => $error, 'message' => $error,
]; ];
if (!empty($errorMessages)) { if (! empty($errorMessages)) {
$response['errors'] = $errorMessages; $response['errors'] = $errorMessages;
} }
@ -40,6 +42,8 @@ public function sendError(string $error, array $errorMessages = [], int $code =
/** /**
* Validation error response method * Validation error response method
*
* @param array<string, mixed> $errors
*/ */
public function sendValidationError(array $errors): JsonResponse public function sendValidationError(array $errors): JsonResponse
{ {

View file

@ -3,9 +3,6 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Services\DashboardStatsService; use App\Services\DashboardStatsService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -40,8 +37,7 @@ public function stats(Request $request): JsonResponse
'current_period' => $period, 'current_period' => $period,
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
throw $e; return $this->sendError('Failed to fetch dashboard stats: '.$e->getMessage(), [], 500);
return $this->sendError('Failed to fetch dashboard stats: ' . $e->getMessage(), [], 500);
} }
} }
} }

View file

@ -10,8 +10,8 @@
use Exception; use Exception;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use InvalidArgumentException;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
class FeedsController extends BaseController class FeedsController extends BaseController
{ {
@ -37,7 +37,7 @@ public function index(Request $request): JsonResponse
'total' => $feeds->total(), 'total' => $feeds->total(),
'from' => $feeds->firstItem(), 'from' => $feeds->firstItem(),
'to' => $feeds->lastItem(), 'to' => $feeds->lastItem(),
] ],
], 'Feeds retrieved successfully.'); ], 'Feeds retrieved successfully.');
} }
@ -64,7 +64,7 @@ public function store(StoreFeedRequest $request, CreateFeedAction $createFeedAct
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
return $this->sendError($e->getMessage(), [], 422); return $this->sendError($e->getMessage(), [], 422);
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to create feed: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to create feed: '.$e->getMessage(), [], 500);
} }
} }
@ -97,7 +97,7 @@ public function update(UpdateFeedRequest $request, Feed $feed): JsonResponse
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->sendValidationError($e->errors()); return $this->sendValidationError($e->errors());
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to update feed: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to update feed: '.$e->getMessage(), [], 500);
} }
} }
@ -114,7 +114,7 @@ public function destroy(Feed $feed): JsonResponse
'Feed deleted successfully!' 'Feed deleted successfully!'
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to delete feed: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to delete feed: '.$e->getMessage(), [], 500);
} }
} }
@ -124,7 +124,7 @@ public function destroy(Feed $feed): JsonResponse
public function toggle(Feed $feed): JsonResponse public function toggle(Feed $feed): JsonResponse
{ {
try { try {
$newStatus = !$feed->is_active; $newStatus = ! $feed->is_active;
$feed->update(['is_active' => $newStatus]); $feed->update(['is_active' => $newStatus]);
$status = $newStatus ? 'activated' : 'deactivated'; $status = $newStatus ? 'activated' : 'deactivated';
@ -134,7 +134,7 @@ public function toggle(Feed $feed): JsonResponse
"Feed {$status} successfully!" "Feed {$status} successfully!"
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to toggle feed status: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to toggle feed status: '.$e->getMessage(), [], 500);
} }
} }
} }

View file

@ -62,7 +62,7 @@ public function store(Request $request, Feed $feed, PlatformChannel $channel): J
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->sendValidationError($e->errors()); return $this->sendValidationError($e->errors());
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->sendError('Failed to create keyword: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to create keyword: '.$e->getMessage(), [], 500);
} }
} }
@ -90,7 +90,7 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel, K
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->sendValidationError($e->errors()); return $this->sendValidationError($e->errors());
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->sendError('Failed to update keyword: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to update keyword: '.$e->getMessage(), [], 500);
} }
} }
@ -112,7 +112,7 @@ public function destroy(Feed $feed, PlatformChannel $channel, Keyword $keyword):
'Keyword deleted successfully!' 'Keyword deleted successfully!'
); );
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->sendError('Failed to delete keyword: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to delete keyword: '.$e->getMessage(), [], 500);
} }
} }
@ -127,7 +127,7 @@ public function toggle(Feed $feed, PlatformChannel $channel, Keyword $keyword):
return $this->sendNotFound('Keyword not found for this route.'); return $this->sendNotFound('Keyword not found for this route.');
} }
$newStatus = !$keyword->is_active; $newStatus = ! $keyword->is_active;
$keyword->update(['is_active' => $newStatus]); $keyword->update(['is_active' => $newStatus]);
$status = $newStatus ? 'activated' : 'deactivated'; $status = $newStatus ? 'activated' : 'deactivated';
@ -137,7 +137,7 @@ public function toggle(Feed $feed, PlatformChannel $channel, Keyword $keyword):
"Keyword {$status} successfully!" "Keyword {$status} successfully!"
); );
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->sendError('Failed to toggle keyword status: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to toggle keyword status: '.$e->getMessage(), [], 500);
} }
} }
} }

View file

@ -49,7 +49,7 @@ public function index(Request $request): JsonResponse
], ],
], 'Logs retrieved successfully.'); ], 'Logs retrieved successfully.');
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->sendError('Failed to retrieve logs: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to retrieve logs: '.$e->getMessage(), [], 500);
} }
} }
} }

View file

@ -31,18 +31,18 @@ public function status(): JsonResponse
// 1. They haven't completed or skipped onboarding AND // 1. They haven't completed or skipped onboarding AND
// 2. They don't have all required components // 2. They don't have all required components
$hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute; $hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute;
$needsOnboarding = !$onboardingCompleted && !$onboardingSkipped && !$hasAllComponents; $needsOnboarding = ! $onboardingCompleted && ! $onboardingSkipped && ! $hasAllComponents;
// Determine current step // Determine current step
$currentStep = null; $currentStep = null;
if ($needsOnboarding) { if ($needsOnboarding) {
if (!$hasPlatformAccount) { if (! $hasPlatformAccount) {
$currentStep = 'platform'; $currentStep = 'platform';
} elseif (!$hasFeed) { } elseif (! $hasFeed) {
$currentStep = 'feed'; $currentStep = 'feed';
} elseif (!$hasChannel) { } elseif (! $hasChannel) {
$currentStep = 'channel'; $currentStep = 'channel';
} elseif (!$hasRoute) { } elseif (! $hasRoute) {
$currentStep = 'route'; $currentStep = 'route';
} }
} }
@ -56,7 +56,7 @@ public function status(): JsonResponse
'has_route' => $hasRoute, 'has_route' => $hasRoute,
'onboarding_skipped' => $onboardingSkipped, 'onboarding_skipped' => $onboardingSkipped,
'onboarding_completed' => $onboardingCompleted, 'onboarding_completed' => $onboardingCompleted,
'missing_components' => !$hasAllComponents && $onboardingCompleted, 'missing_components' => ! $hasAllComponents && $onboardingCompleted,
], 'Onboarding status retrieved successfully.'); ], 'Onboarding status retrieved successfully.');
} }
@ -84,8 +84,10 @@ public function options(): JsonResponse
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']); ->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
// Get feed providers from config // Get feed providers from config
$feedProviders = collect(config('feed.providers', [])) /** @var array<string, array<string, mixed>> $providers */
->filter(fn($provider) => $provider['is_active']) $providers = config('feed.providers', []);
$feedProviders = collect($providers)
->filter(fn (array $provider) => $provider['is_active'])
->values(); ->values();
return $this->sendResponse([ return $this->sendResponse([

View file

@ -53,6 +53,7 @@ public function store(StorePlatformAccountRequest $request, CreatePlatformAccoun
if (str_contains($e->getMessage(), 'Rate limited by')) { if (str_contains($e->getMessage(), 'Rate limited by')) {
return $this->sendError($e->getMessage(), [], 429); return $this->sendError($e->getMessage(), [], 429);
} }
return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422); return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422);
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Unable to connect to the Lemmy instance. Please check the URL and try again.', [], 422); return $this->sendError('Unable to connect to the Lemmy instance. Please check the URL and try again.', [], 422);
@ -97,7 +98,7 @@ public function update(Request $request, PlatformAccount $platformAccount): Json
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->sendValidationError($e->errors()); return $this->sendValidationError($e->errors());
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to update platform account: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to update platform account: '.$e->getMessage(), [], 500);
} }
} }
@ -114,7 +115,7 @@ public function destroy(PlatformAccount $platformAccount): JsonResponse
'Platform account deleted successfully!' 'Platform account deleted successfully!'
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to delete platform account: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to delete platform account: '.$e->getMessage(), [], 500);
} }
} }
@ -131,7 +132,7 @@ public function setActive(PlatformAccount $platformAccount): JsonResponse
"Set {$platformAccount->username}@{$platformAccount->instance_url} as active for {$platformAccount->platform->value}!" "Set {$platformAccount->username}@{$platformAccount->instance_url} as active for {$platformAccount->platform->value}!"
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to set platform account as active: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to set platform account as active: '.$e->getMessage(), [], 500);
} }
} }
} }

View file

@ -5,8 +5,8 @@
use App\Actions\CreateChannelAction; use App\Actions\CreateChannelAction;
use App\Http\Requests\StorePlatformChannelRequest; use App\Http\Requests\StorePlatformChannelRequest;
use App\Http\Resources\PlatformChannelResource; use App\Http\Resources\PlatformChannelResource;
use App\Models\PlatformChannel;
use App\Models\PlatformAccount; use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use Exception; use Exception;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -54,7 +54,7 @@ public function store(StorePlatformChannelRequest $request, CreateChannelAction
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
return $this->sendError($e->getMessage(), [], 422); return $this->sendError($e->getMessage(), [], 422);
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to create platform channel: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to create platform channel: '.$e->getMessage(), [], 500);
} }
} }
@ -91,7 +91,7 @@ public function update(Request $request, PlatformChannel $platformChannel): Json
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->sendValidationError($e->errors()); return $this->sendValidationError($e->errors());
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to update platform channel: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to update platform channel: '.$e->getMessage(), [], 500);
} }
} }
@ -108,7 +108,7 @@ public function destroy(PlatformChannel $platformChannel): JsonResponse
'Platform channel deleted successfully!' 'Platform channel deleted successfully!'
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to delete platform channel: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to delete platform channel: '.$e->getMessage(), [], 500);
} }
} }
@ -118,7 +118,7 @@ public function destroy(PlatformChannel $platformChannel): JsonResponse
public function toggle(PlatformChannel $channel): JsonResponse public function toggle(PlatformChannel $channel): JsonResponse
{ {
try { try {
$newStatus = !$channel->is_active; $newStatus = ! $channel->is_active;
$channel->update(['is_active' => $newStatus]); $channel->update(['is_active' => $newStatus]);
$status = $newStatus ? 'activated' : 'deactivated'; $status = $newStatus ? 'activated' : 'deactivated';
@ -128,7 +128,7 @@ public function toggle(PlatformChannel $channel): JsonResponse
"Platform channel {$status} successfully!" "Platform channel {$status} successfully!"
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to toggle platform channel status: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to toggle platform channel status: '.$e->getMessage(), [], 500);
} }
} }
@ -144,6 +144,7 @@ public function attachAccount(PlatformChannel $channel, Request $request): JsonR
'priority' => 'nullable|integer|min:1|max:100', 'priority' => 'nullable|integer|min:1|max:100',
]); ]);
/** @var PlatformAccount $platformAccount */
$platformAccount = PlatformAccount::findOrFail($validated['platform_account_id']); $platformAccount = PlatformAccount::findOrFail($validated['platform_account_id']);
// Check if account is already attached // Check if account is already attached
@ -165,7 +166,7 @@ public function attachAccount(PlatformChannel $channel, Request $request): JsonR
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->sendValidationError($e->errors()); return $this->sendValidationError($e->errors());
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to attach platform account: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to attach platform account: '.$e->getMessage(), [], 500);
} }
} }
@ -175,7 +176,7 @@ public function attachAccount(PlatformChannel $channel, Request $request): JsonR
public function detachAccount(PlatformChannel $channel, PlatformAccount $account): JsonResponse public function detachAccount(PlatformChannel $channel, PlatformAccount $account): JsonResponse
{ {
try { try {
if (!$channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) { if (! $channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) {
return $this->sendError('Platform account is not attached to this channel.', [], 422); return $this->sendError('Platform account is not attached to this channel.', [], 422);
} }
@ -186,7 +187,7 @@ public function detachAccount(PlatformChannel $channel, PlatformAccount $account
'Platform account detached from channel successfully!' 'Platform account detached from channel successfully!'
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to detach platform account: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to detach platform account: '.$e->getMessage(), [], 500);
} }
} }
@ -201,7 +202,7 @@ public function updateAccountRelation(PlatformChannel $channel, PlatformAccount
'priority' => 'nullable|integer|min:1|max:100', 'priority' => 'nullable|integer|min:1|max:100',
]); ]);
if (!$channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) { if (! $channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) {
return $this->sendError('Platform account is not attached to this channel.', [], 422); return $this->sendError('Platform account is not attached to this channel.', [], 422);
} }
@ -218,7 +219,7 @@ public function updateAccountRelation(PlatformChannel $channel, PlatformAccount
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->sendValidationError($e->errors()); return $this->sendValidationError($e->errors());
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to update platform account relationship: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to update platform account relationship: '.$e->getMessage(), [], 500);
} }
} }
} }

View file

@ -52,7 +52,7 @@ public function store(StoreRouteRequest $request, CreateRouteAction $createRoute
201 201
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to create routing configuration: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to create routing configuration: '.$e->getMessage(), [], 500);
} }
} }
@ -63,7 +63,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse
{ {
$route = $this->findRoute($feed, $channel); $route = $this->findRoute($feed, $channel);
if (!$route) { if (! $route) {
return $this->sendNotFound('Routing configuration not found.'); return $this->sendNotFound('Routing configuration not found.');
} }
@ -83,7 +83,7 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel):
try { try {
$route = $this->findRoute($feed, $channel); $route = $this->findRoute($feed, $channel);
if (!$route) { if (! $route) {
return $this->sendNotFound('Routing configuration not found.'); return $this->sendNotFound('Routing configuration not found.');
} }
@ -103,7 +103,7 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel):
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->sendValidationError($e->errors()); return $this->sendValidationError($e->errors());
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to update routing configuration: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to update routing configuration: '.$e->getMessage(), [], 500);
} }
} }
@ -115,7 +115,7 @@ public function destroy(Feed $feed, PlatformChannel $channel): JsonResponse
try { try {
$route = $this->findRoute($feed, $channel); $route = $this->findRoute($feed, $channel);
if (!$route) { if (! $route) {
return $this->sendNotFound('Routing configuration not found.'); return $this->sendNotFound('Routing configuration not found.');
} }
@ -128,7 +128,7 @@ public function destroy(Feed $feed, PlatformChannel $channel): JsonResponse
'Routing configuration deleted successfully!' 'Routing configuration deleted successfully!'
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to delete routing configuration: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to delete routing configuration: '.$e->getMessage(), [], 500);
} }
} }
@ -140,11 +140,11 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
try { try {
$route = $this->findRoute($feed, $channel); $route = $this->findRoute($feed, $channel);
if (!$route) { if (! $route) {
return $this->sendNotFound('Routing configuration not found.'); return $this->sendNotFound('Routing configuration not found.');
} }
$newStatus = !$route->is_active; $newStatus = ! $route->is_active;
Route::where('feed_id', $feed->id) Route::where('feed_id', $feed->id)
->where('platform_channel_id', $channel->id) ->where('platform_channel_id', $channel->id)
->update(['is_active' => $newStatus]); ->update(['is_active' => $newStatus]);
@ -156,7 +156,7 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
"Routing configuration {$status} successfully!" "Routing configuration {$status} successfully!"
); );
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Failed to toggle routing configuration status: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to toggle routing configuration status: '.$e->getMessage(), [], 500);
} }
} }

View file

@ -23,7 +23,7 @@ public function index(): JsonResponse
return $this->sendResponse($settings, 'Settings retrieved successfully.'); return $this->sendResponse($settings, 'Settings retrieved successfully.');
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->sendError('Failed to retrieve settings: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to retrieve settings: '.$e->getMessage(), [], 500);
} }
} }
@ -64,7 +64,7 @@ public function update(Request $request): JsonResponse
} catch (ValidationException $e) { } catch (ValidationException $e) {
return $this->sendValidationError($e->errors()); return $this->sendValidationError($e->errors());
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to update settings: '.$e->getMessage(), [], 500);
} }
} }
} }

View file

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\EmailVerificationRequest; use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -19,7 +20,9 @@ public function __invoke(EmailVerificationRequest $request): RedirectResponse
} }
if ($request->user()->markEmailAsVerified()) { if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user())); /** @var MustVerifyEmail $user */
$user = $request->user();
event(new Verified($user));
} }
return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); return redirect()->intended(route('dashboard', absolute: false).'?verified=1');

View file

@ -20,7 +20,7 @@ public function __construct(
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if (!$this->onboardingService->needsOnboarding()) { if (! $this->onboardingService->needsOnboarding()) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }

View file

@ -23,7 +23,7 @@ public function rules(): array
'provider' => "required|in:{$providers}", 'provider' => "required|in:{$providers}",
'language_id' => 'required|exists:languages,id', 'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string', 'description' => 'nullable|string',
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
} }
} }

View file

@ -19,11 +19,11 @@ public function rules(): array
{ {
return [ return [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'url' => 'required|url|unique:feeds,url,' . ($this->route('feed') instanceof Feed ? (string)$this->route('feed')->id : (string)$this->route('feed')), 'url' => 'required|url|unique:feeds,url,'.($this->route('feed') instanceof Feed ? (string) $this->route('feed')->id : (string) $this->route('feed')),
'type' => 'required|in:website,rss', 'type' => 'required|in:website,rss',
'language_id' => 'required|exists:languages,id', 'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string', 'description' => 'nullable|string',
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
} }
} }

View file

@ -5,6 +5,9 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\ArticlePublication
*/
class ArticlePublicationResource extends JsonResource class ArticlePublicationResource extends JsonResource
{ {
/** /**
@ -17,8 +20,8 @@ public function toArray(Request $request): array
return [ return [
'id' => $this->id, 'id' => $this->id,
'article_id' => $this->article_id, 'article_id' => $this->article_id,
'status' => $this->status, 'platform' => $this->platform,
'published_at' => $this->published_at?->toISOString(), 'published_at' => $this->published_at->toISOString(),
'created_at' => $this->created_at->toISOString(), 'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(), 'updated_at' => $this->updated_at->toISOString(),
]; ];

View file

@ -6,10 +6,13 @@
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/** /**
* @property int $id * @mixin \App\Models\Article
*/ */
class ArticleResource extends JsonResource class ArticleResource extends JsonResource
{ {
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
@ -19,12 +22,8 @@ public function toArray(Request $request): array
'title' => $this->title, 'title' => $this->title,
'description' => $this->description, 'description' => $this->description,
'is_valid' => $this->is_valid, 'is_valid' => $this->is_valid,
'is_duplicate' => $this->is_duplicate,
'approval_status' => $this->approval_status, 'approval_status' => $this->approval_status,
'publish_status' => $this->publish_status, 'publish_status' => $this->publish_status,
'approved_at' => $this->approved_at?->toISOString(),
'approved_by' => $this->approved_by,
'fetched_at' => $this->fetched_at?->toISOString(),
'validated_at' => $this->validated_at?->toISOString(), 'validated_at' => $this->validated_at?->toISOString(),
'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null, 'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null,
'created_at' => $this->created_at->toISOString(), 'created_at' => $this->created_at->toISOString(),

View file

@ -5,6 +5,9 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\Feed
*/
class FeedResource extends JsonResource class FeedResource extends JsonResource
{ {
/** /**

View file

@ -5,6 +5,12 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\PlatformAccount
*/
/**
* @mixin \App\Models\PlatformAccount
*/
class PlatformAccountResource extends JsonResource class PlatformAccountResource extends JsonResource
{ {
/** /**

View file

@ -5,6 +5,9 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\PlatformChannel
*/
class PlatformChannelResource extends JsonResource class PlatformChannelResource extends JsonResource
{ {
/** /**

View file

@ -5,6 +5,9 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\PlatformInstance
*/
class PlatformInstanceResource extends JsonResource class PlatformInstanceResource extends JsonResource
{ {
/** /**

View file

@ -5,6 +5,9 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\Route
*/
class RouteResource extends JsonResource class RouteResource extends JsonResource
{ {
/** /**
@ -15,7 +18,6 @@ class RouteResource extends JsonResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
'id' => $this->id,
'feed_id' => $this->feed_id, 'feed_id' => $this->feed_id,
'platform_channel_id' => $this->platform_channel_id, 'platform_channel_id' => $this->platform_channel_id,
'is_active' => $this->is_active, 'is_active' => $this->is_active,

View file

@ -25,7 +25,7 @@ public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void
$logSaver->info('Starting feed article fetch', null, [ $logSaver->info('Starting feed article fetch', null, [
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,
'feed_name' => $this->feed->name, 'feed_name' => $this->feed->name,
'feed_url' => $this->feed->url 'feed_url' => $this->feed->url,
]); ]);
$articles = $articleFetcher->getArticlesFromFeed($this->feed); $articles = $articleFetcher->getArticlesFromFeed($this->feed);
@ -33,7 +33,7 @@ public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void
$logSaver->info('Feed article fetch completed', null, [ $logSaver->info('Feed article fetch completed', null, [
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,
'feed_name' => $this->feed->name, 'feed_name' => $this->feed->name,
'articles_count' => $articles->count() 'articles_count' => $articles->count(),
]); ]);
$this->feed->update(['last_fetched_at' => now()]); $this->feed->update(['last_fetched_at' => now()]);
@ -56,7 +56,7 @@ public static function dispatchForAllActiveFeeds(): void
$logSaver->info('Dispatched feed discovery job', null, [ $logSaver->info('Dispatched feed discovery job', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_name' => $feed->name, 'feed_name' => $feed->name,
'delay_minutes' => $delayMinutes 'delay_minutes' => $delayMinutes,
]); ]);
}); });
} }

View file

@ -18,7 +18,7 @@ public function __construct()
public function handle(LogSaver $logSaver): void public function handle(LogSaver $logSaver): void
{ {
if (!Setting::isArticleProcessingEnabled()) { if (! Setting::isArticleProcessingEnabled()) {
$logSaver->info('Article processing is disabled. Article discovery skipped.'); $logSaver->info('Article processing is disabled. Article discovery skipped.');
return; return;

View file

@ -2,6 +2,8 @@
namespace App\Jobs; namespace App\Jobs;
use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed;
use App\Exceptions\PublishException; use App\Exceptions\PublishException;
use App\Models\Article; use App\Models\Article;
use App\Models\ArticlePublication; use App\Models\ArticlePublication;
@ -12,7 +14,7 @@
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
class PublishNextArticleJob implements ShouldQueue, ShouldBeUnique class PublishNextArticleJob implements ShouldBeUnique, ShouldQueue
{ {
use Queueable; use Queueable;
@ -28,6 +30,7 @@ public function __construct()
/** /**
* Execute the job. * Execute the job.
*
* @throws PublishException * @throws PublishException
*/ */
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void
@ -52,11 +55,11 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
return; return;
} }
logger()->info('Publishing next article from scheduled job', [ ActionPerformed::dispatch('Publishing next article from scheduled job', LogLevelEnum::INFO, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title, 'title' => $article->title,
'url' => $article->url, 'url' => $article->url,
'created_at' => $article->created_at 'created_at' => $article->created_at,
]); ]);
// Fetch article data // Fetch article data
@ -65,14 +68,14 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
try { try {
$publishingService->publishToRoutedChannels($article, $extractedData); $publishingService->publishToRoutedChannels($article, $extractedData);
logger()->info('Successfully published article', [ ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title 'title' => $article->title,
]); ]);
} catch (PublishException $e) { } catch (PublishException $e) {
logger()->error('Failed to publish article', [ ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
throw $e; throw $e;

View file

@ -15,7 +15,7 @@
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
class SyncChannelPostsJob implements ShouldQueue, ShouldBeUnique class SyncChannelPostsJob implements ShouldBeUnique, ShouldQueue
{ {
use Queueable; use Queueable;
@ -78,7 +78,7 @@ private function syncLemmyChannelPosts(LogSaver $logSaver): void
} catch (Exception $e) { } catch (Exception $e) {
$logSaver->error('Failed to sync channel posts', $this->channel, [ $logSaver->error('Failed to sync channel posts', $this->channel, [
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
throw $e; throw $e;
@ -97,13 +97,13 @@ private function getAuthToken(LemmyApiService $api, PlatformAccount $account): s
return $cachedToken; return $cachedToken;
} }
if (!$account->username || !$account->password) { if (! $account->username || ! $account->password) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account'); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account');
} }
$token = $api->login($account->username, $account->password); $token = $api->login($account->username, $account->password);
if (!$token) { if (! $token) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account'); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account');
} }

View file

@ -0,0 +1,21 @@
<?php
namespace App\Listeners;
use App\Events\ActionPerformed;
use App\Services\Log\LogSaver;
use Exception;
class LogActionListener
{
public function __construct(private LogSaver $logSaver) {}
public function handle(ActionPerformed $event): void
{
try {
$this->logSaver->log($event->level, $event->message, context: $event->context);
} catch (Exception $e) {
error_log('Failed to log action to database: '.$e->getMessage());
}
}
}

View file

@ -5,14 +5,14 @@
use App\Events\ExceptionLogged; use App\Events\ExceptionLogged;
use App\Events\ExceptionOccurred; use App\Events\ExceptionOccurred;
use App\Models\Log; use App\Models\Log;
class LogExceptionToDatabase class LogExceptionToDatabase
{ {
public function handle(ExceptionOccurred $event): void public function handle(ExceptionOccurred $event): void
{ {
// Truncate the message to prevent database errors // Truncate the message to prevent database errors
$message = strlen($event->message) > 255 $message = strlen($event->message) > 255
? substr($event->message, 0, 252) . '...' ? substr($event->message, 0, 252).'...'
: $event->message; : $event->message;
try { try {
@ -24,15 +24,15 @@ public function handle(ExceptionOccurred $event): void
'file' => $event->exception->getFile(), 'file' => $event->exception->getFile(),
'line' => $event->exception->getLine(), 'line' => $event->exception->getLine(),
'trace' => $event->exception->getTraceAsString(), 'trace' => $event->exception->getTraceAsString(),
...$event->context ...$event->context,
] ],
]); ]);
ExceptionLogged::dispatch($log); ExceptionLogged::dispatch($log);
} catch (\Exception $e) { } catch (\Exception $e) {
// Prevent infinite recursion by not logging this exception // Prevent infinite recursion by not logging this exception
// Optionally log to file or other non-database destination // Optionally log to file or other non-database destination
error_log("Failed to log exception to database: " . $e->getMessage()); error_log('Failed to log exception to database: '.$e->getMessage());
} }
} }
} }

View file

@ -2,6 +2,8 @@
namespace App\Listeners; namespace App\Listeners;
use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed;
use App\Events\ArticleApproved; use App\Events\ArticleApproved;
use App\Services\Article\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
@ -40,14 +42,14 @@ public function handle(ArticleApproved $event): void
if ($publications->isNotEmpty()) { if ($publications->isNotEmpty()) {
$article->update(['publish_status' => 'published']); $article->update(['publish_status' => 'published']);
logger()->info('Published approved article', [ ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title, 'title' => $article->title,
]); ]);
} else { } else {
$article->update(['publish_status' => 'error']); $article->update(['publish_status' => 'error']);
logger()->warning('No publications created for approved article', [ ActionPerformed::dispatch('No publications created for approved article', LogLevelEnum::WARNING, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title, 'title' => $article->title,
]); ]);
@ -55,7 +57,7 @@ public function handle(ArticleApproved $event): void
} catch (Exception $e) { } catch (Exception $e) {
$article->update(['publish_status' => 'error']); $article->update(['publish_status' => 'error']);
logger()->error('Failed to publish approved article', [ ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);

View file

@ -2,6 +2,8 @@
namespace App\Listeners; namespace App\Listeners;
use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use App\Models\Setting; use App\Models\Setting;
use App\Services\Article\ValidationService; use App\Services\Article\ValidationService;
@ -37,10 +39,11 @@ public function handle(NewArticleFetched $event): void
try { try {
$article = $this->validationService->validate($article); $article = $this->validationService->validate($article);
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('Article validation failed', [ ActionPerformed::dispatch('Article validation failed', LogLevelEnum::ERROR, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
return; return;
} }

View file

@ -2,9 +2,9 @@
namespace App\Livewire; namespace App\Livewire;
use App\Jobs\ArticleDiscoveryJob;
use App\Models\Article; use App\Models\Article;
use App\Models\Setting; use App\Models\Setting;
use App\Jobs\ArticleDiscoveryJob;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@ -39,7 +39,7 @@ public function refresh(): void
$this->dispatch('refresh-started'); $this->dispatch('refresh-started');
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
$articles = Article::with(['feed', 'articlePublication']) $articles = Article::with(['feed', 'articlePublication'])
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')

View file

@ -13,7 +13,7 @@ class Channels extends Component
public function toggle(int $channelId): void public function toggle(int $channelId): void
{ {
$channel = PlatformChannel::findOrFail($channelId); $channel = PlatformChannel::findOrFail($channelId);
$channel->is_active = !$channel->is_active; $channel->is_active = ! $channel->is_active;
$channel->save(); $channel->save();
} }
@ -29,13 +29,13 @@ public function closeAccountModal(): void
public function attachAccount(int $accountId): void public function attachAccount(int $accountId): void
{ {
if (!$this->managingChannelId) { if (! $this->managingChannelId) {
return; return;
} }
$channel = PlatformChannel::findOrFail($this->managingChannelId); $channel = PlatformChannel::findOrFail($this->managingChannelId);
if (!$channel->platformAccounts()->where('platform_account_id', $accountId)->exists()) { if (! $channel->platformAccounts()->where('platform_account_id', $accountId)->exists()) {
$channel->platformAccounts()->attach($accountId, [ $channel->platformAccounts()->attach($accountId, [
'is_active' => true, 'is_active' => true,
'priority' => 1, 'priority' => 1,
@ -51,7 +51,7 @@ public function detachAccount(int $channelId, int $accountId): void
$channel->platformAccounts()->detach($accountId); $channel->platformAccounts()->detach($accountId);
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
$channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get(); $channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get();
$allAccounts = PlatformAccount::where('is_active', true)->get(); $allAccounts = PlatformAccount::where('is_active', true)->get();
@ -61,7 +61,7 @@ public function render()
: null; : null;
$availableAccounts = $managingChannel $availableAccounts = $managingChannel
? $allAccounts->filter(fn($account) => !$managingChannel->platformAccounts->contains('id', $account->id)) ? $allAccounts->filter(fn ($account) => ! $managingChannel->platformAccounts->contains('id', $account->id))
: collect(); : collect();
return view('livewire.channels', [ return view('livewire.channels', [

View file

@ -19,7 +19,7 @@ public function setPeriod(string $period): void
$this->period = $period; $this->period = $period;
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
$service = app(DashboardStatsService::class); $service = app(DashboardStatsService::class);

View file

@ -10,11 +10,11 @@ class Feeds extends Component
public function toggle(int $feedId): void public function toggle(int $feedId): void
{ {
$feed = Feed::findOrFail($feedId); $feed = Feed::findOrFail($feedId);
$feed->is_active = !$feed->is_active; $feed->is_active = ! $feed->is_active;
$feed->save(); $feed->save();
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
$feeds = Feed::orderBy('name')->get(); $feeds = Feed::orderBy('name')->get();

View file

@ -29,36 +29,54 @@ class Onboarding extends Component
// Platform form // Platform form
public string $instanceUrl = ''; public string $instanceUrl = '';
public string $username = ''; public string $username = '';
public string $password = ''; public string $password = '';
/** @var array<string, mixed>|null */
public ?array $existingAccount = null; public ?array $existingAccount = null;
// Feed form // Feed form
public string $feedName = ''; public string $feedName = '';
public string $feedProvider = 'vrt'; public string $feedProvider = 'vrt';
public ?int $feedLanguageId = null; public ?int $feedLanguageId = null;
public string $feedDescription = ''; public string $feedDescription = '';
// Channel form // Channel form
public string $channelName = ''; public string $channelName = '';
public ?int $platformInstanceId = null; public ?int $platformInstanceId = null;
public ?int $channelLanguageId = null; public ?int $channelLanguageId = null;
public string $channelDescription = ''; public string $channelDescription = '';
// Route form // Route form
public ?int $routeFeedId = null; public ?int $routeFeedId = null;
public ?int $routeChannelId = null; public ?int $routeChannelId = null;
public int $routePriority = 50; public int $routePriority = 50;
// State // State
/** @var array<string, string> */
public array $formErrors = []; public array $formErrors = [];
public bool $isLoading = false; public bool $isLoading = false;
#[\Livewire\Attributes\Locked] #[\Livewire\Attributes\Locked]
public ?int $previousChannelLanguageId = null; public ?int $previousChannelLanguageId = null;
protected CreatePlatformAccountAction $createPlatformAccountAction; protected CreatePlatformAccountAction $createPlatformAccountAction;
protected CreateFeedAction $createFeedAction; protected CreateFeedAction $createFeedAction;
protected CreateChannelAction $createChannelAction; protected CreateChannelAction $createChannelAction;
protected CreateRouteAction $createRouteAction; protected CreateRouteAction $createRouteAction;
public function boot( public function boot(
@ -188,7 +206,7 @@ public function createPlatformAccount(): void
} }
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('Lemmy platform account creation failed', [ logger()->error('Lemmy platform account creation failed', [
'instance_url' => 'https://' . $this->instanceUrl, 'instance_url' => 'https://'.$this->instanceUrl,
'username' => $this->username, 'username' => $this->username,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'class' => get_class($e), 'class' => get_class($e),
@ -319,17 +337,20 @@ public function completeOnboarding(): void
/** /**
* Get language codes that have at least one active provider. * Get language codes that have at least one active provider.
*/ */
/**
* @return list<string>
*/
public function getAvailableLanguageCodes(): array public function getAvailableLanguageCodes(): array
{ {
$providers = config('feed.providers', []); $providers = config('feed.providers', []);
$languageCodes = []; $languageCodes = [];
foreach ($providers as $provider) { foreach ($providers as $provider) {
if (!($provider['is_active'] ?? false)) { if (! ($provider['is_active'] ?? false)) {
continue; continue;
} }
foreach (array_keys($provider['languages'] ?? []) as $code) { foreach (array_keys($provider['languages'] ?? []) as $code) {
$languageCodes[$code] = true; $languageCodes[(string) $code] = true;
} }
} }
@ -339,14 +360,17 @@ public function getAvailableLanguageCodes(): array
/** /**
* Get providers available for the current channel language. * Get providers available for the current channel language.
*/ */
/**
* @return array<int, array<string, string>>
*/
public function getProvidersForLanguage(): array public function getProvidersForLanguage(): array
{ {
if (!$this->channelLanguageId) { if (! $this->channelLanguageId) {
return []; return [];
} }
$language = Language::find($this->channelLanguageId); $language = Language::find($this->channelLanguageId);
if (!$language) { if (! $language) {
return []; return [];
} }
@ -355,7 +379,7 @@ public function getProvidersForLanguage(): array
$available = []; $available = [];
foreach ($providers as $key => $provider) { foreach ($providers as $key => $provider) {
if (!($provider['is_active'] ?? false)) { if (! ($provider['is_active'] ?? false)) {
continue; continue;
} }
if (isset($provider['languages'][$langCode])) { if (isset($provider['languages'][$langCode])) {
@ -375,13 +399,14 @@ public function getProvidersForLanguage(): array
*/ */
public function getChannelLanguage(): ?Language public function getChannelLanguage(): ?Language
{ {
if (!$this->channelLanguageId) { if (! $this->channelLanguageId) {
return null; return null;
} }
return Language::find($this->channelLanguageId); return Language::find($this->channelLanguageId);
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
// For channel step: only show languages that have providers // For channel step: only show languages that have providers
$availableCodes = $this->getAvailableLanguageCodes(); $availableCodes = $this->getAvailableLanguageCodes();

View file

@ -11,12 +11,16 @@
class Routes extends Component class Routes extends Component
{ {
public bool $showCreateModal = false; public bool $showCreateModal = false;
public ?int $editingFeedId = null; public ?int $editingFeedId = null;
public ?int $editingChannelId = null; public ?int $editingChannelId = null;
// Create form // Create form
public ?int $newFeedId = null; public ?int $newFeedId = null;
public ?int $newChannelId = null; public ?int $newChannelId = null;
public int $newPriority = 50; public int $newPriority = 50;
// Edit form // Edit form
@ -24,6 +28,7 @@ class Routes extends Component
// Keyword management // Keyword management
public string $newKeyword = ''; public string $newKeyword = '';
public bool $showKeywordInput = false; public bool $showKeywordInput = false;
public function openCreateModal(): void public function openCreateModal(): void
@ -53,6 +58,7 @@ public function createRoute(): void
if ($exists) { if ($exists) {
$this->addError('newFeedId', 'This route already exists.'); $this->addError('newFeedId', 'This route already exists.');
return; return;
} }
@ -87,7 +93,7 @@ public function closeEditModal(): void
public function updateRoute(): void public function updateRoute(): void
{ {
if (!$this->editingFeedId || !$this->editingChannelId) { if (! $this->editingFeedId || ! $this->editingChannelId) {
return; return;
} }
@ -108,7 +114,7 @@ public function toggle(int $feedId, int $channelId): void
->where('platform_channel_id', $channelId) ->where('platform_channel_id', $channelId)
->firstOrFail(); ->firstOrFail();
$route->is_active = !$route->is_active; $route->is_active = ! $route->is_active;
$route->save(); $route->save();
} }
@ -126,7 +132,7 @@ public function delete(int $feedId, int $channelId): void
public function addKeyword(): void public function addKeyword(): void
{ {
if (!$this->editingFeedId || !$this->editingChannelId || empty(trim($this->newKeyword))) { if (! $this->editingFeedId || ! $this->editingChannelId || empty(trim($this->newKeyword))) {
return; return;
} }
@ -144,7 +150,7 @@ public function addKeyword(): void
public function toggleKeyword(int $keywordId): void public function toggleKeyword(int $keywordId): void
{ {
$keyword = Keyword::findOrFail($keywordId); $keyword = Keyword::findOrFail($keywordId);
$keyword->is_active = !$keyword->is_active; $keyword->is_active = ! $keyword->is_active;
$keyword->save(); $keyword->save();
} }
@ -153,22 +159,23 @@ public function deleteKeyword(int $keywordId): void
Keyword::destroy($keywordId); Keyword::destroy($keywordId);
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
$routes = Route::with(['feed', 'platformChannel']) $routes = Route::with(['feed', 'platformChannel.platformInstance'])
->orderBy('priority', 'desc') ->orderBy('priority', 'desc')
->get(); ->get();
// Batch load keywords for all routes to avoid N+1 queries // Batch load keywords for all routes to avoid N+1 queries
$routeKeys = $routes->map(fn($r) => $r->feed_id . '-' . $r->platform_channel_id); $routeKeys = $routes->map(fn ($r) => $r->feed_id.'-'.$r->platform_channel_id);
$allKeywords = Keyword::whereIn('feed_id', $routes->pluck('feed_id')) $allKeywords = Keyword::whereIn('feed_id', $routes->pluck('feed_id'))
->whereIn('platform_channel_id', $routes->pluck('platform_channel_id')) ->whereIn('platform_channel_id', $routes->pluck('platform_channel_id'))
->get() ->get()
->groupBy(fn($k) => $k->feed_id . '-' . $k->platform_channel_id); ->groupBy(fn ($k) => $k->feed_id.'-'.$k->platform_channel_id);
$routes = $routes->map(function ($route) use ($allKeywords) { $routes = $routes->map(function ($route) use ($allKeywords) {
$key = $route->feed_id . '-' . $route->platform_channel_id; $key = $route->feed_id.'-'.$route->platform_channel_id;
$route->keywords = $allKeywords->get($key, collect()); $route->setRelation('keywords', $allKeywords->get($key, collect()));
return $route; return $route;
}); });
@ -179,7 +186,7 @@ public function render()
$editingKeywords = collect(); $editingKeywords = collect();
if ($this->editingFeedId && $this->editingChannelId) { if ($this->editingFeedId && $this->editingChannelId) {
$editingRoute = Route::with(['feed', 'platformChannel']) $editingRoute = Route::with(['feed', 'platformChannel.platformInstance'])
->where('feed_id', $this->editingFeedId) ->where('feed_id', $this->editingFeedId)
->where('platform_channel_id', $this->editingChannelId) ->where('platform_channel_id', $this->editingChannelId)
->first(); ->first();

View file

@ -8,10 +8,13 @@
class Settings extends Component class Settings extends Component
{ {
public bool $articleProcessingEnabled = true; public bool $articleProcessingEnabled = true;
public bool $publishingApprovalsEnabled = false; public bool $publishingApprovalsEnabled = false;
public int $articlePublishingInterval = 5; public int $articlePublishingInterval = 5;
public ?string $successMessage = null; public ?string $successMessage = null;
public ?string $errorMessage = null; public ?string $errorMessage = null;
public function mount(): void public function mount(): void
@ -23,14 +26,14 @@ public function mount(): void
public function toggleArticleProcessing(): void public function toggleArticleProcessing(): void
{ {
$this->articleProcessingEnabled = !$this->articleProcessingEnabled; $this->articleProcessingEnabled = ! $this->articleProcessingEnabled;
Setting::setArticleProcessingEnabled($this->articleProcessingEnabled); Setting::setArticleProcessingEnabled($this->articleProcessingEnabled);
$this->showSuccess(); $this->showSuccess();
} }
public function togglePublishingApprovals(): void public function togglePublishingApprovals(): void
{ {
$this->publishingApprovalsEnabled = !$this->publishingApprovalsEnabled; $this->publishingApprovalsEnabled = ! $this->publishingApprovalsEnabled;
Setting::setPublishingApprovalsEnabled($this->publishingApprovalsEnabled); Setting::setPublishingApprovalsEnabled($this->publishingApprovalsEnabled);
$this->showSuccess(); $this->showSuccess();
} }
@ -60,7 +63,7 @@ public function clearMessages(): void
$this->errorMessage = null; $this->errorMessage = null;
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
return view('livewire.settings')->layout('layouts.app'); return view('livewire.settings')->layout('layouts.app');
} }

View file

@ -15,15 +15,20 @@
* @method static firstOrCreate(array<string, mixed> $array) * @method static firstOrCreate(array<string, mixed> $array)
* @method static where(string $string, string $url) * @method static where(string $string, string $url)
* @method static create(array<string, mixed> $array) * @method static create(array<string, mixed> $array)
* @property integer $id *
* @property int $id
* @property int $feed_id * @property int $feed_id
* @property Feed $feed * @property Feed $feed
* @property string $url * @property string $url
* @property string $title
* @property string|null $description
* @property string $approval_status
* @property string $publish_status
* @property bool|null $is_valid * @property bool|null $is_valid
* @property Carbon|null $validated_at * @property Carbon|null $validated_at
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at
* @property ArticlePublication $articlePublication * @property ArticlePublication|null $articlePublication
*/ */
class Article extends Model class Article extends Model
{ {
@ -79,7 +84,7 @@ public function isRejected(): bool
return $this->approval_status === 'rejected'; return $this->approval_status === 'rejected';
} }
public function approve(string $approvedBy = null): void public function approve(?string $approvedBy = null): void
{ {
$this->update([ $this->update([
'approval_status' => 'approved', 'approval_status' => 'approved',
@ -89,7 +94,7 @@ public function approve(string $approvedBy = null): void
event(new ArticleApproved($this)); event(new ArticleApproved($this));
} }
public function reject(string $rejectedBy = null): void public function reject(?string $rejectedBy = null): void
{ {
$this->update([ $this->update([
'approval_status' => 'rejected', 'approval_status' => 'rejected',
@ -98,12 +103,12 @@ public function reject(string $rejectedBy = null): void
public function canBePublished(): bool public function canBePublished(): bool
{ {
if (!$this->isValid()) { if (! $this->isValid()) {
return false; return false;
} }
// If approval system is disabled, auto-approve valid articles // If approval system is disabled, auto-approve valid articles
if (!\App\Models\Setting::isPublishingApprovalsEnabled()) { if (! \App\Models\Setting::isPublishingApprovalsEnabled()) {
return true; return true;
} }

View file

@ -8,9 +8,16 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* @property integer $article_id * @property int $id
* @property integer $platform_channel_id * @property int $article_id
* @property integer $post_id * @property int $platform_channel_id
* @property string $post_id
* @property string $platform
* @property string $published_by
* @property array<string, mixed>|null $publication_data
* @property \Illuminate\Support\Carbon $published_at
* @property \Illuminate\Support\Carbon $created_at
* @property \Illuminate\Support\Carbon $updated_at
* *
* @method static create(array<string, mixed> $array) * @method static create(array<string, mixed> $array)
*/ */

View file

@ -16,7 +16,7 @@
* @property string $url * @property string $url
* @property string $type * @property string $type
* @property string $provider * @property string $provider
* @property int $language_id * @property int|null $language_id
* @property Language|null $language * @property Language|null $language
* @property string $description * @property string $description
* @property array<string, mixed> $settings * @property array<string, mixed> $settings
@ -24,6 +24,7 @@
* @property Carbon|null $last_fetched_at * @property Carbon|null $last_fetched_at
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at
*
* @method static orderBy(string $string, string $string1) * @method static orderBy(string $string, string $string1)
* @method static where(string $string, true $true) * @method static where(string $string, true $true)
* @method static findOrFail(mixed $feed_id) * @method static findOrFail(mixed $feed_id)
@ -32,7 +33,9 @@ class Feed extends Model
{ {
/** @use HasFactory<FeedFactory> */ /** @use HasFactory<FeedFactory> */
use HasFactory; use HasFactory;
private const RECENT_FETCH_THRESHOLD_HOURS = 2; private const RECENT_FETCH_THRESHOLD_HOURS = 2;
private const DAILY_FETCH_THRESHOLD_HOURS = 24; private const DAILY_FETCH_THRESHOLD_HOURS = 24;
protected $fillable = [ protected $fillable = [
@ -44,13 +47,13 @@ class Feed extends Model
'description', 'description',
'settings', 'settings',
'is_active', 'is_active',
'last_fetched_at' 'last_fetched_at',
]; ];
protected $casts = [ protected $casts = [
'settings' => 'array', 'settings' => 'array',
'is_active' => 'boolean', 'is_active' => 'boolean',
'last_fetched_at' => 'datetime' 'last_fetched_at' => 'datetime',
]; ];
public function getTypeDisplayAttribute(): string public function getTypeDisplayAttribute(): string
@ -64,11 +67,11 @@ public function getTypeDisplayAttribute(): string
public function getStatusAttribute(): string public function getStatusAttribute(): string
{ {
if (!$this->is_active) { if (! $this->is_active) {
return 'Inactive'; return 'Inactive';
} }
if (!$this->last_fetched_at) { if (! $this->last_fetched_at) {
return 'Never fetched'; return 'Never fetched';
} }
@ -79,12 +82,12 @@ public function getStatusAttribute(): string
} elseif ($hoursAgo < self::DAILY_FETCH_THRESHOLD_HOURS) { } elseif ($hoursAgo < self::DAILY_FETCH_THRESHOLD_HOURS) {
return "Fetched {$hoursAgo}h ago"; return "Fetched {$hoursAgo}h ago";
} else { } else {
return "Fetched " . $this->last_fetched_at->diffForHumans(); return 'Fetched '.$this->last_fetched_at->diffForHumans();
} }
} }
/** /**
* @return BelongsToMany<PlatformChannel, $this, Route> * @return BelongsToMany<PlatformChannel, $this>
*/ */
public function channels(): BelongsToMany public function channels(): BelongsToMany
{ {
@ -94,7 +97,7 @@ public function channels(): BelongsToMany
} }
/** /**
* @return BelongsToMany<PlatformChannel, $this, Route> * @return BelongsToMany<PlatformChannel, $this>
*/ */
public function activeChannels(): BelongsToMany public function activeChannels(): BelongsToMany
{ {

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Database\Factories\KeywordFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -20,17 +21,18 @@
*/ */
class Keyword extends Model class Keyword extends Model
{ {
/** @use HasFactory<KeywordFactory> */
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'feed_id', 'feed_id',
'platform_channel_id', 'platform_channel_id',
'keyword', 'keyword',
'is_active' 'is_active',
]; ];
protected $casts = [ protected $casts = [
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
/** /**
@ -48,5 +50,4 @@ public function platformChannel(): BelongsTo
{ {
return $this->belongsTo(PlatformChannel::class); return $this->belongsTo(PlatformChannel::class);
} }
} }

View file

@ -17,11 +17,11 @@ class Language extends Model
'short_code', 'short_code',
'name', 'name',
'native_name', 'native_name',
'is_active' 'is_active',
]; ];
protected $casts = [ protected $casts = [
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
/** /**

View file

@ -3,12 +3,14 @@
namespace App\Models; namespace App\Models;
use App\Enums\LogLevelEnum; use App\Enums\LogLevelEnum;
use Database\Factories\LogFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @method static create(array $array) * @method static create(array<string, mixed> $array)
*
* @property LogLevelEnum $level * @property LogLevelEnum $level
* @property string $message * @property string $message
* @property array<string, mixed> $context * @property array<string, mixed> $context
@ -17,6 +19,7 @@
*/ */
class Log extends Model class Log extends Model
{ {
/** @use HasFactory<LogFactory> */
use HasFactory; use HasFactory;
protected $table = 'logs'; protected $table = 'logs';

View file

@ -2,15 +2,15 @@
namespace App\Models; namespace App\Models;
use App\Enums\PlatformEnum;
use Database\Factories\PlatformAccountFactory; use Database\Factories\PlatformAccountFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use App\Enums\PlatformEnum;
/** /**
* @property int $id * @property int $id
@ -18,13 +18,14 @@
* @property string $instance_url * @property string $instance_url
* @property string $username * @property string $username
* @property string $password * @property string $password
* @property string $settings * @property array<string, mixed> $settings
* @property bool $is_active * @property bool $is_active
* @property Carbon $last_tested_at * @property Carbon|null $last_tested_at
* @property string $status * @property string $status
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at
* @property Collection<int, PlatformChannel> $activeChannels * @property Collection<int, PlatformChannel> $activeChannels
*
* @method static where(string $string, PlatformEnum $platform) * @method static where(string $string, PlatformEnum $platform)
* @method static orderBy(string $string) * @method static orderBy(string $string)
* @method static create(array<string, mixed> $validated) * @method static create(array<string, mixed> $validated)
@ -42,14 +43,14 @@ class PlatformAccount extends Model
'settings', 'settings',
'is_active', 'is_active',
'last_tested_at', 'last_tested_at',
'status' 'status',
]; ];
protected $casts = [ protected $casts = [
'platform' => PlatformEnum::class, 'platform' => PlatformEnum::class,
'settings' => 'array', 'settings' => 'array',
'is_active' => 'boolean', 'is_active' => 'boolean',
'last_tested_at' => 'datetime' 'last_tested_at' => 'datetime',
]; ];
// Encrypt password when storing // Encrypt password when storing
@ -93,7 +94,6 @@ protected function password(): Attribute
)->withoutObjectCaching(); )->withoutObjectCaching();
} }
// Get the active accounts for a platform (returns collection) // Get the active accounts for a platform (returns collection)
/** /**
* @return Collection<int, PlatformAccount> * @return Collection<int, PlatformAccount>

View file

@ -10,15 +10,16 @@
/** /**
* @method static findMany(mixed $channel_ids) * @method static findMany(mixed $channel_ids)
* @method static create(array $array) * @method static create(array<string, mixed> $array)
* @property integer $id *
* @property integer $platform_instance_id * @property int $id
* @property int $platform_instance_id
* @property PlatformInstance $platformInstance * @property PlatformInstance $platformInstance
* @property integer $channel_id * @property int $channel_id
* @property string $name * @property string $name
* @property int $language_id * @property int $language_id
* @property Language|null $language * @property Language|null $language
* @property boolean $is_active * @property bool $is_active
*/ */
class PlatformChannel extends Model class PlatformChannel extends Model
{ {
@ -34,11 +35,11 @@ class PlatformChannel extends Model
'channel_id', 'channel_id',
'description', 'description',
'language_id', 'language_id',
'is_active' 'is_active',
]; ];
protected $casts = [ protected $casts = [
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
/** /**
@ -70,11 +71,11 @@ public function activePlatformAccounts(): BelongsToMany
public function getFullNameAttribute(): string public function getFullNameAttribute(): string
{ {
// For Lemmy, use /c/ prefix // For Lemmy, use /c/ prefix
return $this->platformInstance->url . '/c/' . $this->name; return $this->platformInstance->url.'/c/'.$this->name;
} }
/** /**
* @return BelongsToMany<Feed, $this, Route> * @return BelongsToMany<Feed, $this>
*/ */
public function feeds(): BelongsToMany public function feeds(): BelongsToMany
{ {
@ -84,7 +85,7 @@ public function feeds(): BelongsToMany
} }
/** /**
* @return BelongsToMany<Feed, $this, Route> * @return BelongsToMany<Feed, $this>
*/ */
public function activeFeeds(): BelongsToMany public function activeFeeds(): BelongsToMany
{ {

View file

@ -12,7 +12,9 @@
*/ */
class PlatformChannelPost extends Model class PlatformChannelPost extends Model
{ {
/** @use HasFactory<\Illuminate\Database\Eloquent\Factories\Factory<PlatformChannelPost>> */
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'platform', 'platform',
'channel_id', 'channel_id',
@ -44,7 +46,7 @@ public static function urlExists(PlatformEnum $platform, string $channelId, stri
public static function duplicateExists(PlatformEnum $platform, string $channelId, ?string $url, ?string $title): bool public static function duplicateExists(PlatformEnum $platform, string $channelId, ?string $url, ?string $title): bool
{ {
if (!$url && !$title) { if (! $url && ! $title) {
return false; return false;
} }

View file

@ -12,11 +12,12 @@
/** /**
* @method static updateOrCreate(array<string, mixed> $array, $instanceData) * @method static updateOrCreate(array<string, mixed> $array, $instanceData)
* @method static where(string $string, mixed $operator) * @method static where(string $string, mixed $operator)
*
* @property PlatformEnum $platform * @property PlatformEnum $platform
* @property string $url * @property string $url
* @property string $name * @property string $name
* @property string $description * @property string $description
* @property boolean $is_active * @property bool $is_active
*/ */
class PlatformInstance extends Model class PlatformInstance extends Model
{ {
@ -28,12 +29,12 @@ class PlatformInstance extends Model
'url', 'url',
'name', 'name',
'description', 'description',
'is_active' 'is_active',
]; ];
protected $casts = [ protected $casts = [
'platform' => PlatformEnum::class, 'platform' => PlatformEnum::class,
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
/** /**

View file

@ -4,9 +4,9 @@
use Database\Factories\RouteFactory; use Database\Factories\RouteFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
@ -26,17 +26,18 @@ class Route extends Model
// Laravel doesn't handle composite primary keys well, so we'll use regular queries // Laravel doesn't handle composite primary keys well, so we'll use regular queries
protected $primaryKey = null; protected $primaryKey = null;
public $incrementing = false; public $incrementing = false;
protected $fillable = [ protected $fillable = [
'feed_id', 'feed_id',
'platform_channel_id', 'platform_channel_id',
'is_active', 'is_active',
'priority' 'priority',
]; ];
protected $casts = [ protected $casts = [
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
/** /**

View file

@ -2,16 +2,18 @@
namespace App\Models; namespace App\Models;
use Database\Factories\SettingFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
* @method static updateOrCreate(string[] $array, array $array1) * @method static updateOrCreate(array<string, string> $array, array<string, mixed> $array1)
* @method static create(string[] $array) * @method static create(array<string, string> $array)
* @method static where(string $string, string $key) * @method static where(string $string, string $key)
*/ */
class Setting extends Model class Setting extends Model
{ {
/** @use HasFactory<SettingFactory> */
use HasFactory; use HasFactory;
protected $fillable = ['key', 'value']; protected $fillable = ['key', 'value'];

View file

@ -11,7 +11,7 @@
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasApiTokens; use HasApiTokens, HasFactory, Notifiable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.

View file

@ -2,13 +2,15 @@
namespace App\Modules\Lemmy; namespace App\Modules\Lemmy;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Response; use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class LemmyRequest class LemmyRequest
{ {
private string $instance; private string $instance;
private ?string $token; private ?string $token;
private string $scheme = 'https'; private string $scheme = 'https';
public function __construct(string $instance, ?string $token = null) public function __construct(string $instance, ?string $token = null)
@ -45,11 +47,12 @@ public function withScheme(string $scheme): self
if (in_array($scheme, ['http', 'https'], true)) { if (in_array($scheme, ['http', 'https'], true)) {
$this->scheme = $scheme; $this->scheme = $scheme;
} }
return $this; return $this;
} }
/** /**
* @param array<string, mixed> $params * @param array<string, mixed> $params
*/ */
public function get(string $endpoint, array $params = []): Response public function get(string $endpoint, array $params = []): Response
{ {
@ -65,7 +68,7 @@ public function get(string $endpoint, array $params = []): Response
} }
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
*/ */
public function post(string $endpoint, array $data = []): Response public function post(string $endpoint, array $data = []): Response
{ {
@ -83,6 +86,7 @@ public function post(string $endpoint, array $data = []): Response
public function withToken(string $token): self public function withToken(string $token): self
{ {
$this->token = $token; $this->token = $token;
return $this; return $this;
} }
} }

View file

@ -39,7 +39,7 @@ public function login(string $username, string $password): ?string
'password' => $password, 'password' => $password,
]); ]);
if (!$response->successful()) { if (! $response->successful()) {
$responseBody = $response->body(); $responseBody = $response->body();
logger()->error('Lemmy login failed', [ logger()->error('Lemmy login failed', [
'status' => $response->status(), 'status' => $response->status(),
@ -61,6 +61,7 @@ public function login(string $username, string $password): ?string
} }
$data = $response->json(); $data = $response->json();
return $data['jwt'] ?? null; return $data['jwt'] ?? null;
} catch (Exception $e) { } catch (Exception $e) {
// Re-throw rate limit exceptions immediately // Re-throw rate limit exceptions immediately
@ -74,7 +75,7 @@ public function login(string $username, string $password): ?string
continue; continue;
} }
// Connection failed - throw exception to distinguish from auth failure // Connection failed - throw exception to distinguish from auth failure
throw new Exception('Connection failed: ' . $e->getMessage()); throw new Exception('Connection failed: '.$e->getMessage());
} }
} }
@ -88,11 +89,12 @@ public function getCommunityId(string $communityName, string $token): int
$request = new LemmyRequest($this->instance, $token); $request = new LemmyRequest($this->instance, $token);
$response = $request->get('community', ['name' => $communityName]); $response = $request->get('community', ['name' => $communityName]);
if (!$response->successful()) { if (! $response->successful()) {
throw new Exception('Failed to fetch community: ' . $response->status()); throw new Exception('Failed to fetch community: '.$response->status());
} }
$data = $response->json(); $data = $response->json();
return $data['community_view']['community']['id'] ?? throw new Exception('Community not found'); return $data['community_view']['community']['id'] ?? throw new Exception('Community not found');
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('Community lookup failed', ['error' => $e->getMessage()]); logger()->error('Community lookup failed', ['error' => $e->getMessage()]);
@ -107,14 +109,15 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
$response = $request->get('post/list', [ $response = $request->get('post/list', [
'community_id' => $platformChannelId, 'community_id' => $platformChannelId,
'limit' => 50, 'limit' => 50,
'sort' => 'New' 'sort' => 'New',
]); ]);
if (!$response->successful()) { if (! $response->successful()) {
logger()->warning('Failed to sync channel posts', [ logger()->warning('Failed to sync channel posts', [
'status' => $response->status(), 'status' => $response->status(),
'platform_channel_id' => $platformChannelId 'platform_channel_id' => $platformChannelId,
]); ]);
return; return;
} }
@ -137,13 +140,13 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
logger()->info('Synced channel posts', [ logger()->info('Synced channel posts', [
'platform_channel_id' => $platformChannelId, 'platform_channel_id' => $platformChannelId,
'posts_count' => count($posts) 'posts_count' => count($posts),
]); ]);
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('Exception while syncing channel posts', [ logger()->error('Exception while syncing channel posts', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'platform_channel_id' => $platformChannelId 'platform_channel_id' => $platformChannelId,
]); ]);
} }
} }
@ -176,8 +179,8 @@ public function createPost(string $token, string $title, string $body, int $plat
$response = $request->post('post', $postData); $response = $request->post('post', $postData);
if (!$response->successful()) { if (! $response->successful()) {
throw new Exception('Failed to create post: ' . $response->status() . ' - ' . $response->body()); throw new Exception('Failed to create post: '.$response->status().' - '.$response->body());
} }
return $response->json(); return $response->json();
@ -196,19 +199,22 @@ public function getLanguages(): array
$request = new LemmyRequest($this->instance); $request = new LemmyRequest($this->instance);
$response = $request->get('site'); $response = $request->get('site');
if (!$response->successful()) { if (! $response->successful()) {
logger()->warning('Failed to fetch site languages', [ logger()->warning('Failed to fetch site languages', [
'status' => $response->status() 'status' => $response->status(),
]); ]);
return []; return [];
} }
$data = $response->json(); $data = $response->json();
return $data['all_languages'] ?? []; return $data['all_languages'] ?? [];
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('Exception while fetching languages', [ logger()->error('Exception while fetching languages', [
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
return []; return [];
} }
} }

View file

@ -12,6 +12,7 @@
class LemmyPublisher class LemmyPublisher
{ {
private LemmyApiService $api; private LemmyApiService $api;
private PlatformAccount $account; private PlatformAccount $account;
public function __construct(PlatformAccount $account) public function __construct(PlatformAccount $account)
@ -21,8 +22,9 @@ public function __construct(PlatformAccount $account)
} }
/** /**
* @param array<string, mixed> $extractedData * @param array<string, mixed> $extractedData
* @return array<string, mixed> * @return array<string, mixed>
*
* @throws PlatformAuthException * @throws PlatformAuthException
* @throws Exception * @throws Exception
*/ */
@ -37,6 +39,7 @@ public function publishToChannel(Article $article, array $extractedData, Platfor
// If the cached token was stale, refresh and retry once // If the cached token was stale, refresh and retry once
if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) { if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) {
$token = $authService->refreshToken($this->account); $token = $authService->refreshToken($this->account);
return $this->createPost($token, $extractedData, $channel, $article); return $this->createPost($token, $extractedData, $channel, $article);
} }
throw $e; throw $e;
@ -44,7 +47,7 @@ public function publishToChannel(Article $article, array $extractedData, Platfor
} }
/** /**
* @param array<string, mixed> $extractedData * @param array<string, mixed> $extractedData
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article): array private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article): array
@ -65,5 +68,4 @@ private function createPost(string $token, array $extractedData, PlatformChannel
$languageId $languageId
); );
} }
} }

View file

@ -3,7 +3,9 @@
namespace App\Providers; namespace App\Providers;
use App\Enums\LogLevelEnum; use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed;
use App\Events\ExceptionOccurred; use App\Events\ExceptionOccurred;
use App\Listeners\LogActionListener;
use App\Listeners\LogExceptionToDatabase; use App\Listeners\LogExceptionToDatabase;
use Error; use Error;
use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Debug\ExceptionHandler;
@ -14,12 +16,15 @@
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
public function register(): void public function register(): void {}
{
}
public function boot(): void public function boot(): void
{ {
Event::listen(
ActionPerformed::class,
LogActionListener::class,
);
Event::listen( Event::listen(
ExceptionOccurred::class, ExceptionOccurred::class,
LogExceptionToDatabase::class, LogExceptionToDatabase::class,

View file

@ -4,9 +4,9 @@
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Services\Http\HttpFetcher;
use App\Services\Factories\ArticleParserFactory; use App\Services\Factories\ArticleParserFactory;
use App\Services\Factories\HomepageParserFactory; use App\Services\Factories\HomepageParserFactory;
use App\Services\Http\HttpFetcher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use Exception; use Exception;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -28,9 +28,9 @@ public function getArticlesFromFeed(Feed $feed): Collection
return $this->getArticlesFromWebsiteFeed($feed); return $this->getArticlesFromWebsiteFeed($feed);
} }
$this->logSaver->warning("Unsupported feed type", null, [ $this->logSaver->warning('Unsupported feed type', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_type' => $feed->type 'feed_type' => $feed->type,
]); ]);
return collect(); return collect();
@ -53,8 +53,8 @@ private function getArticlesFromRssFeed(Feed $feed): Collection
libxml_use_internal_errors($previousUseErrors); libxml_use_internal_errors($previousUseErrors);
} }
if ($rss === false || !isset($rss->channel->item)) { if ($rss === false || ! isset($rss->channel->item)) {
$this->logSaver->warning("Failed to parse RSS feed XML", null, [ $this->logSaver->warning('Failed to parse RSS feed XML', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_url' => $feed->url, 'feed_url' => $feed->url,
]); ]);
@ -72,7 +72,7 @@ private function getArticlesFromRssFeed(Feed $feed): Collection
return $articles; return $articles;
} catch (Exception $e) { } catch (Exception $e) {
$this->logSaver->error("Failed to fetch articles from RSS feed", null, [ $this->logSaver->error('Failed to fetch articles from RSS feed', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_url' => $feed->url, 'feed_url' => $feed->url,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
@ -92,9 +92,9 @@ private function getArticlesFromWebsiteFeed(Feed $feed): Collection
$parser = HomepageParserFactory::getParserForFeed($feed); $parser = HomepageParserFactory::getParserForFeed($feed);
if (! $parser) { if (! $parser) {
$this->logSaver->warning("No parser available for feed URL", null, [ $this->logSaver->warning('No parser available for feed URL', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_url' => $feed->url 'feed_url' => $feed->url,
]); ]);
return collect(); return collect();
@ -107,10 +107,10 @@ private function getArticlesFromWebsiteFeed(Feed $feed): Collection
->map(fn (string $url) => $this->saveArticle($url, $feed->id)); ->map(fn (string $url) => $this->saveArticle($url, $feed->id));
} catch (Exception $e) { } catch (Exception $e) {
$this->logSaver->error("Failed to fetch articles from website feed", null, [ $this->logSaver->error('Failed to fetch articles from website feed', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_url' => $feed->url, 'feed_url' => $feed->url,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
return collect(); return collect();
@ -130,7 +130,7 @@ public function fetchArticleData(Article $article): array
} catch (Exception $e) { } catch (Exception $e) {
$this->logSaver->error('Exception while fetching article data', null, [ $this->logSaver->error('Exception while fetching article data', null, [
'url' => $article->url, 'url' => $article->url,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
return []; return [];
@ -156,7 +156,7 @@ private function saveArticle(string $url, ?int $feedId = null): Article
return $article; return $article;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logSaver->error("Failed to create article", null, [ $this->logSaver->error('Failed to create article', null, [
'url' => $url, 'url' => $url,
'feed_id' => $feedId, 'feed_id' => $feedId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),

View file

@ -12,23 +12,23 @@ public function __construct(
public function validate(Article $article): Article public function validate(Article $article): Article
{ {
logger('Checking keywords for article: ' . $article->id); logger('Checking keywords for article: '.$article->id);
$articleData = $this->articleFetcher->fetchArticleData($article); $articleData = $this->articleFetcher->fetchArticleData($article);
// Update article with fetched metadata (title, description) // Update article with fetched metadata (title, description)
$updateData = []; $updateData = [];
if (!empty($articleData)) { if (! empty($articleData)) {
$updateData['title'] = $articleData['title'] ?? $article->title; $updateData['title'] = $articleData['title'] ?? $article->title;
$updateData['description'] = $articleData['description'] ?? $article->description; $updateData['description'] = $articleData['description'] ?? $article->description;
$updateData['content'] = $articleData['full_article'] ?? null; $updateData['content'] = $articleData['full_article'] ?? null;
} }
if (!isset($articleData['full_article']) || empty($articleData['full_article'])) { if (! isset($articleData['full_article']) || empty($articleData['full_article'])) {
logger()->warning('Article data missing full_article content', [ logger()->warning('Article data missing full_article content', [
'article_id' => $article->id, 'article_id' => $article->id,
'url' => $article->url 'url' => $article->url,
]); ]);
$updateData['approval_status'] = 'rejected'; $updateData['approval_status'] = 'rejected';
@ -67,7 +67,7 @@ private function validateByKeywords(string $full_article): bool
// Common Belgian news topics // Common Belgian news topics
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy',
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police' 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police',
]; ];
foreach ($keywords as $keyword) { foreach ($keywords as $keyword) {

View file

@ -32,14 +32,14 @@ public function getToken(PlatformAccount $account): string
public function refreshToken(PlatformAccount $account): string public function refreshToken(PlatformAccount $account): string
{ {
if (! $account->username || ! $account->password || ! $account->instance_url) { if (! $account->username || ! $account->password || ! $account->instance_url) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: '.$account->username);
} }
$api = new LemmyApiService($account->instance_url); $api = new LemmyApiService($account->instance_url);
$token = $api->login($account->username, $account->password); $token = $api->login($account->username, $account->password);
if (!$token) { if (! $token) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: '.$account->username);
} }
// Cache the token for future use // Cache the token for future use
@ -52,15 +52,19 @@ public function refreshToken(PlatformAccount $account): string
/** /**
* Authenticate with Lemmy API and return user data with JWT * Authenticate with Lemmy API and return user data with JWT
*
* @throws PlatformAuthException * @throws PlatformAuthException
*/ */
/**
* @return array<string, mixed>
*/
public function authenticate(string $instanceUrl, string $username, string $password): array public function authenticate(string $instanceUrl, string $username, string $password): array
{ {
try { try {
$api = new LemmyApiService($instanceUrl); $api = new LemmyApiService($instanceUrl);
$token = $api->login($username, $password); $token = $api->login($username, $password);
if (!$token) { if (! $token) {
// Throw a clean exception that will be caught and handled by the controller // Throw a clean exception that will be caught and handled by the controller
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Invalid credentials'); throw new PlatformAuthException(PlatformEnum::LEMMY, 'Invalid credentials');
} }
@ -75,8 +79,8 @@ public function authenticate(string $instanceUrl, string $username, string $pass
'id' => 0, // Would need API call to get actual user info 'id' => 0, // Would need API call to get actual user info
'display_name' => null, 'display_name' => null,
'bio' => null, 'bio' => null,
] ],
] ],
]; ];
} catch (PlatformAuthException $e) { } catch (PlatformAuthException $e) {
// Re-throw PlatformAuthExceptions as-is to avoid nesting // Re-throw PlatformAuthExceptions as-is to avoid nesting

View file

@ -9,10 +9,12 @@
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\Route; use App\Models\Route;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class DashboardStatsService class DashboardStatsService
{ {
/**
* @return array<string, mixed>
*/
public function getStats(string $period = 'today'): array public function getStats(string $period = 'today'): array
{ {
$dateRange = $this->getDateRange($period); $dateRange = $this->getDateRange($period);
@ -73,6 +75,9 @@ private function getDateRange(string $period): ?array
}; };
} }
/**
* @return array<string, int>
*/
public function getSystemStats(): array public function getSystemStats(): array
{ {
$totalFeeds = Feed::query()->count(); $totalFeeds = Feed::query()->count();

View file

@ -4,9 +4,9 @@
use App\Contracts\ArticleParserInterface; use App\Contracts\ArticleParserInterface;
use App\Models\Feed; use App\Models\Feed;
use App\Services\Parsers\VrtArticleParser;
use App\Services\Parsers\BelgaArticleParser; use App\Services\Parsers\BelgaArticleParser;
use App\Services\Parsers\GuardianArticleParser; use App\Services\Parsers\GuardianArticleParser;
use App\Services\Parsers\VrtArticleParser;
use Exception; use Exception;
class ArticleParserFactory class ArticleParserFactory
@ -26,7 +26,7 @@ class ArticleParserFactory
public static function getParser(string $url): ArticleParserInterface public static function getParser(string $url): ArticleParserInterface
{ {
foreach (self::$parsers as $parserClass) { foreach (self::$parsers as $parserClass) {
$parser = new $parserClass(); $parser = new $parserClass;
if ($parser->canParse($url)) { if ($parser->canParse($url)) {
return $parser; return $parser;
@ -38,21 +38,22 @@ public static function getParser(string $url): ArticleParserInterface
public static function getParserForFeed(Feed $feed, string $parserType = 'article'): ?ArticleParserInterface public static function getParserForFeed(Feed $feed, string $parserType = 'article'): ?ArticleParserInterface
{ {
if (!$feed->provider) { if (! $feed->provider) {
return null; return null;
} }
$providerConfig = config("feed.providers.{$feed->provider}"); $providerConfig = config("feed.providers.{$feed->provider}");
if (!$providerConfig || !isset($providerConfig['parsers'][$parserType])) { if (! $providerConfig || ! isset($providerConfig['parsers'][$parserType])) {
return null; return null;
} }
/** @var class-string<ArticleParserInterface> $parserClass */
$parserClass = $providerConfig['parsers'][$parserType]; $parserClass = $providerConfig['parsers'][$parserType];
if (!class_exists($parserClass)) { if (! class_exists($parserClass)) {
return null; return null;
} }
return new $parserClass(); return new $parserClass;
} }
/** /**
@ -60,18 +61,19 @@ public static function getParserForFeed(Feed $feed, string $parserType = 'articl
*/ */
public static function getSupportedSources(): array public static function getSupportedSources(): array
{ {
return array_map(function($parserClass) { return array_map(function ($parserClass) {
$parser = new $parserClass(); $parser = new $parserClass;
return $parser->getSourceName(); return $parser->getSourceName();
}, self::$parsers); }, self::$parsers);
} }
/** /**
* @param class-string<ArticleParserInterface> $parserClass * @param class-string<ArticleParserInterface> $parserClass
*/ */
public static function registerParser(string $parserClass): void public static function registerParser(string $parserClass): void
{ {
if (!in_array($parserClass, self::$parsers)) { if (! in_array($parserClass, self::$parsers)) {
self::$parsers[] = $parserClass; self::$parsers[] = $parserClass;
} }
} }

View file

@ -9,21 +9,22 @@ class HomepageParserFactory
{ {
public static function getParserForFeed(Feed $feed): ?HomepageParserInterface public static function getParserForFeed(Feed $feed): ?HomepageParserInterface
{ {
if (!$feed->provider) { if (! $feed->provider) {
return null; return null;
} }
$providerConfig = config("feed.providers.{$feed->provider}"); $providerConfig = config("feed.providers.{$feed->provider}");
if (!$providerConfig || !isset($providerConfig['parsers']['homepage'])) { if (! $providerConfig || ! isset($providerConfig['parsers']['homepage'])) {
return null; return null;
} }
/** @var class-string<HomepageParserInterface> $parserClass */
$parserClass = $providerConfig['parsers']['homepage']; $parserClass = $providerConfig['parsers']['homepage'];
if (!class_exists($parserClass)) { if (! class_exists($parserClass)) {
return null; return null;
} }
$language = $feed->language?->short_code ?? 'en'; $language = $feed->language->short_code ?? 'en';
return new $parserClass($language); return new $parserClass($language);
} }

View file

@ -2,8 +2,8 @@
namespace App\Services\Http; namespace App\Services\Http;
use Illuminate\Support\Facades\Http;
use Exception; use Exception;
use Illuminate\Support\Facades\Http;
class HttpFetcher class HttpFetcher
{ {
@ -15,7 +15,7 @@ public static function fetchHtml(string $url): string
try { try {
$response = Http::get($url); $response = Http::get($url);
if (!$response->successful()) { if (! $response->successful()) {
throw new Exception("Failed to fetch URL: {$url} - Status: {$response->status()}"); throw new Exception("Failed to fetch URL: {$url} - Status: {$response->status()}");
} }
@ -23,7 +23,7 @@ public static function fetchHtml(string $url): string
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('HTTP fetch failed', [ logger()->error('HTTP fetch failed', [
'url' => $url, 'url' => $url,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
throw $e; throw $e;
@ -31,7 +31,7 @@ public static function fetchHtml(string $url): string
} }
/** /**
* @param array<int, string> $urls * @param array<int, string> $urls
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
public static function fetchMultipleUrls(array $urls): array public static function fetchMultipleUrls(array $urls): array
@ -44,24 +44,24 @@ public static function fetchMultipleUrls(array $urls): array
}); });
return collect($responses) return collect($responses)
->filter(fn($response, $index) => isset($urls[$index])) ->filter(fn ($response, $index) => isset($urls[$index]))
->reject(fn($response, $index) => $response instanceof Exception) ->reject(fn ($response, $index) => $response instanceof Exception)
->map(function ($response, $index) use ($urls) { ->map(function ($response, $index) use ($urls) {
$url = $urls[$index]; $url = $urls[$index];
/** @var \Illuminate\Http\Client\Response $response */
try { try {
if ($response->successful()) { if ($response->successful()) {
return [ return [
'url' => $url, 'url' => $url,
'html' => $response->body(), 'html' => $response->body(),
'success' => true 'success' => true,
]; ];
} else { } else {
return [ return [
'url' => $url, 'url' => $url,
'html' => null, 'html' => null,
'success' => false, 'success' => false,
'status' => $response->status() 'status' => $response->status(),
]; ];
} }
} catch (Exception) { } catch (Exception) {
@ -69,11 +69,10 @@ public static function fetchMultipleUrls(array $urls): array
'url' => $url, 'url' => $url,
'html' => null, 'html' => null,
'success' => false, 'success' => false,
'error' => 'Exception occurred' 'error' => 'Exception occurred',
]; ];
} }
}) })
->filter(fn($result) => $result !== null)
->toArray(); ->toArray();
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('Multiple URL fetch failed', ['error' => $e->getMessage()]); logger()->error('Multiple URL fetch failed', ['error' => $e->getMessage()]);

View file

@ -9,7 +9,7 @@
class LogSaver class LogSaver
{ {
/** /**
* @param array<string, mixed> $context * @param array<string, mixed> $context
*/ */
public function info(string $message, ?PlatformChannel $channel = null, array $context = []): void public function info(string $message, ?PlatformChannel $channel = null, array $context = []): void
{ {
@ -17,7 +17,7 @@ public function info(string $message, ?PlatformChannel $channel = null, array $c
} }
/** /**
* @param array<string, mixed> $context * @param array<string, mixed> $context
*/ */
public function error(string $message, ?PlatformChannel $channel = null, array $context = []): void public function error(string $message, ?PlatformChannel $channel = null, array $context = []): void
{ {
@ -25,7 +25,7 @@ public function error(string $message, ?PlatformChannel $channel = null, array $
} }
/** /**
* @param array<string, mixed> $context * @param array<string, mixed> $context
*/ */
public function warning(string $message, ?PlatformChannel $channel = null, array $context = []): void public function warning(string $message, ?PlatformChannel $channel = null, array $context = []): void
{ {
@ -33,7 +33,7 @@ public function warning(string $message, ?PlatformChannel $channel = null, array
} }
/** /**
* @param array<string, mixed> $context * @param array<string, mixed> $context
*/ */
public function debug(string $message, ?PlatformChannel $channel = null, array $context = []): void public function debug(string $message, ?PlatformChannel $channel = null, array $context = []): void
{ {
@ -41,9 +41,9 @@ public function debug(string $message, ?PlatformChannel $channel = null, array $
} }
/** /**
* @param array<string, mixed> $context * @param array<string, mixed> $context
*/ */
private function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void public function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void
{ {
$logContext = $context; $logContext = $context;

View file

@ -41,6 +41,6 @@ private function checkOnboardingStatus(): bool
$hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute; $hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute;
return !$hasAllComponents; return ! $hasAllComponents;
} }
} }

View file

@ -57,12 +57,12 @@ public static function extractFullArticle(string $html): ?string
// Look for Belga-specific paragraph class // Look for Belga-specific paragraph class
if (preg_match_all('/<p[^>]*class="[^"]*styles_paragraph__[^"]*"[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches)) { if (preg_match_all('/<p[^>]*class="[^"]*styles_paragraph__[^"]*"[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches)) {
$paragraphs = array_map(function($paragraph) { $paragraphs = array_map(function ($paragraph) {
return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8'); return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8');
}, $matches[1]); }, $matches[1]);
// Filter out empty paragraphs and join with double newlines // Filter out empty paragraphs and join with double newlines
$fullText = implode("\n\n", array_filter($paragraphs, function($p) { $fullText = implode("\n\n", array_filter($paragraphs, function ($p) {
return trim($p) !== ''; return trim($p) !== '';
})); }));
@ -74,13 +74,13 @@ public static function extractFullArticle(string $html): ?string
$sectionHtml = $sectionMatches[1]; $sectionHtml = $sectionMatches[1];
preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $sectionHtml, $matches); preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $sectionHtml, $matches);
if (!empty($matches[1])) { if (! empty($matches[1])) {
$paragraphs = array_map(function($paragraph) { $paragraphs = array_map(function ($paragraph) {
return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8'); return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8');
}, $matches[1]); }, $matches[1]);
// Filter out empty paragraphs and join with double newlines // Filter out empty paragraphs and join with double newlines
$fullText = implode("\n\n", array_filter($paragraphs, function($p) { $fullText = implode("\n\n", array_filter($paragraphs, function ($p) {
return trim($p) !== ''; return trim($p) !== '';
})); }));
@ -90,13 +90,13 @@ public static function extractFullArticle(string $html): ?string
// Final fallback: Extract all paragraph content // Final fallback: Extract all paragraph content
preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches); preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches);
if (!empty($matches[1])) { if (! empty($matches[1])) {
$paragraphs = array_map(function($paragraph) { $paragraphs = array_map(function ($paragraph) {
return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8'); return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8');
}, $matches[1]); }, $matches[1]);
// Filter out empty paragraphs and join with double newlines // Filter out empty paragraphs and join with double newlines
$fullText = implode("\n\n", array_filter($paragraphs, function($p) { $fullText = implode("\n\n", array_filter($paragraphs, function ($p) {
return trim($p) !== ''; return trim($p) !== '';
})); }));

View file

@ -1,48 +0,0 @@
<?php
namespace App\Services\Parsers;
class BelgaHomepageParser
{
/**
* @return array<int, string>
*/
public static function extractArticleUrls(string $html): array
{
// Find all relative article links (most articles use relative paths)
preg_match_all('/<a[^>]+href="(\/[a-z0-9-]+)"/', $html, $matches);
// Blacklist of non-article paths
$blacklistPaths = [
'/',
'/de',
'/feed',
'/search',
'/category',
'/about',
'/contact',
'/privacy',
'/terms',
];
$urls = collect($matches[1])
->unique()
->filter(function ($path) use ($blacklistPaths) {
// Exclude exact matches and paths starting with blacklisted paths
foreach ($blacklistPaths as $blacklistedPath) {
if ($path === $blacklistedPath || str_starts_with($path, $blacklistedPath . '/')) {
return false;
}
}
return true;
})
->map(function ($path) {
// Convert relative paths to absolute URLs
return 'https://www.belganewsagency.eu' . $path;
})
->values()
->toArray();
return $urls;
}
}

View file

@ -1,32 +0,0 @@
<?php
namespace App\Services\Parsers;
use App\Contracts\HomepageParserInterface;
class BelgaHomepageParserAdapter implements HomepageParserInterface
{
public function __construct(
private string $language = 'en',
) {}
public function canParse(string $url): bool
{
return str_contains($url, 'belganewsagency.eu');
}
public function extractArticleUrls(string $html): array
{
return BelgaHomepageParser::extractArticleUrls($html);
}
public function getHomepageUrl(): string
{
return 'https://www.belganewsagency.eu/';
}
public function getSourceName(): string
{
return 'Belga News Agency';
}
}

View file

@ -50,14 +50,14 @@ public static function extractFullArticle(string $html): ?string
$sectionHtml = $sectionMatches[1]; $sectionHtml = $sectionMatches[1];
preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $sectionHtml, $matches); preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $sectionHtml, $matches);
if (!empty($matches[1])) { if (! empty($matches[1])) {
return self::joinParagraphs($matches[1]); return self::joinParagraphs($matches[1]);
} }
} }
// Fallback: extract all paragraph content // Fallback: extract all paragraph content
preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches); preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches);
if (!empty($matches[1])) { if (! empty($matches[1])) {
return self::joinParagraphs($matches[1]); return self::joinParagraphs($matches[1]);
} }
@ -93,7 +93,7 @@ public static function extractData(string $html): array
} }
/** /**
* @param array<int, string> $paragraphs * @param array<int, string> $paragraphs
*/ */
private static function joinParagraphs(array $paragraphs): ?string private static function joinParagraphs(array $paragraphs): ?string
{ {

View file

@ -48,13 +48,13 @@ public static function extractFullArticle(string $html): ?string
// Extract all paragraph content // Extract all paragraph content
preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches); preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches);
if (!empty($matches[1])) { if (! empty($matches[1])) {
$paragraphs = array_map(function($paragraph) { $paragraphs = array_map(function ($paragraph) {
return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8'); return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8');
}, $matches[1]); }, $matches[1]);
// Filter out empty paragraphs and join with double newlines // Filter out empty paragraphs and join with double newlines
$fullText = implode("\n\n", array_filter($paragraphs, function($p) { $fullText = implode("\n\n", array_filter($paragraphs, function ($p) {
return trim($p) !== ''; return trim($p) !== '';
})); }));

View file

@ -10,11 +10,11 @@ class VrtHomepageParser
public static function extractArticleUrls(string $html, string $language = 'en'): array public static function extractArticleUrls(string $html, string $language = 'en'): array
{ {
$escapedLanguage = preg_quote($language, '/'); $escapedLanguage = preg_quote($language, '/');
preg_match_all('/href="(?:https:\/\/www\.vrt\.be)?(\/vrtnws\/' . $escapedLanguage . '\/\d{4}\/\d{2}\/\d{2}\/[^"]+)"/', $html, $matches); preg_match_all('/href="(?:https:\/\/www\.vrt\.be)?(\/vrtnws\/'.$escapedLanguage.'\/\d{4}\/\d{2}\/\d{2}\/[^"]+)"/', $html, $matches);
$urls = collect($matches[1]) $urls = collect($matches[1])
->unique() ->unique()
->map(fn ($path) => 'https://www.vrt.be' . $path) ->map(fn ($path) => 'https://www.vrt.be'.$path)
->toArray(); ->toArray();
return $urls; return $urls;

View file

@ -12,15 +12,13 @@
use App\Modules\Lemmy\Services\LemmyPublisher; use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use RuntimeException; use RuntimeException;
class ArticlePublishingService class ArticlePublishingService
{ {
public function __construct(private LogSaver $logSaver) public function __construct(private LogSaver $logSaver) {}
{
}
/** /**
* Factory seam to create publisher instances (helps testing without network calls) * Factory seam to create publisher instances (helps testing without network calls)
*/ */
@ -28,9 +26,11 @@ protected function makePublisher(mixed $account): LemmyPublisher
{ {
return new LemmyPublisher($account); return new LemmyPublisher($account);
} }
/** /**
* @param array<string, mixed> $extractedData * @param array<string, mixed> $extractedData
* @return Collection<int, ArticlePublication> * @return Collection<int, ArticlePublication>
*
* @throws PublishException * @throws PublishException
*/ */
public function publishToRoutedChannels(Article $article, array $extractedData): Collection public function publishToRoutedChannels(Article $article, array $extractedData): Collection
@ -60,7 +60,7 @@ public function publishToRoutedChannels(Article $article, array $extractedData):
if (! $account) { if (! $account) {
$this->logSaver->warning('No active account for channel', $channel, [ $this->logSaver->warning('No active account for channel', $channel, [
'article_id' => $article->id, 'article_id' => $article->id,
'route_priority' => $route->priority 'route_priority' => $route->priority,
]); ]);
return null; return null;
@ -68,12 +68,13 @@ public function publishToRoutedChannels(Article $article, array $extractedData):
return $this->publishToChannel($article, $extractedData, $channel, $account); return $this->publishToChannel($article, $extractedData, $channel, $account);
}) })
->filter(); ->filter();
} }
/** /**
* Check if a route matches an article based on keywords * Check if a route matches an article based on keywords
* @param array<string, mixed> $extractedData *
* @param array<string, mixed> $extractedData
*/ */
private function routeMatchesArticle(Route $route, array $extractedData): bool private function routeMatchesArticle(Route $route, array $extractedData): bool
{ {
@ -91,10 +92,10 @@ private function routeMatchesArticle(Route $route, array $extractedData): bool
$articleContent = $extractedData['full_article']; $articleContent = $extractedData['full_article'];
} }
if (isset($extractedData['title'])) { if (isset($extractedData['title'])) {
$articleContent .= ' ' . $extractedData['title']; $articleContent .= ' '.$extractedData['title'];
} }
if (isset($extractedData['description'])) { if (isset($extractedData['description'])) {
$articleContent .= ' ' . $extractedData['description']; $articleContent .= ' '.$extractedData['description'];
} }
// Check if any of the route's keywords match the article content // Check if any of the route's keywords match the article content
@ -109,7 +110,7 @@ private function routeMatchesArticle(Route $route, array $extractedData): bool
} }
/** /**
* @param array<string, mixed> $extractedData * @param array<string, mixed> $extractedData
*/ */
private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication
{ {
@ -145,14 +146,14 @@ private function publishToChannel(Article $article, array $extractedData, Platfo
]); ]);
$this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [ $this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [
'article_id' => $article->id 'article_id' => $article->id,
]); ]);
return $publication; return $publication;
} catch (Exception $e) { } catch (Exception $e) {
$this->logSaver->warning('Failed to publish to channel', $channel, [ $this->logSaver->warning('Failed to publish to channel', $channel, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
return null; return null;

View file

@ -10,7 +10,8 @@
class RoutingValidationService class RoutingValidationService
{ {
/** /**
* @param Collection<int, PlatformChannel> $channels * @param Collection<int, PlatformChannel> $channels
*
* @throws RoutingMismatchException * @throws RoutingMismatchException
*/ */
public function validateLanguageCompatibility(Feed $feed, Collection $channels): void public function validateLanguageCompatibility(Feed $feed, Collection $channels): void

View file

@ -3,8 +3,8 @@
namespace App\Services; namespace App\Services;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Route;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\Route;
use App\Models\Setting; use App\Models\Setting;
class SystemStatusService class SystemStatusService
@ -17,22 +17,22 @@ public function getSystemStatus(): array
$reasons = []; $reasons = [];
$isEnabled = true; $isEnabled = true;
if (!Setting::isArticleProcessingEnabled()) { if (! Setting::isArticleProcessingEnabled()) {
$isEnabled = false; $isEnabled = false;
$reasons[] = 'Manually disabled by user'; $reasons[] = 'Manually disabled by user';
} }
if (!Feed::where('is_active', true)->exists()) { if (! Feed::where('is_active', true)->exists()) {
$isEnabled = false; $isEnabled = false;
$reasons[] = 'No active feeds configured'; $reasons[] = 'No active feeds configured';
} }
if (!PlatformChannel::where('is_active', true)->exists()) { if (! PlatformChannel::where('is_active', true)->exists()) {
$isEnabled = false; $isEnabled = false;
$reasons[] = 'No active platform channels configured'; $reasons[] = 'No active platform channels configured';
} }
if (!Route::where('is_active', true)->exists()) { if (! Route::where('is_active', true)->exists()) {
$isEnabled = false; $isEnabled = false;
$reasons[] = 'No active feed-to-channel routes configured'; $reasons[] = 'No active feed-to-channel routes configured';
} }

View file

@ -29,6 +29,7 @@
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"phpstan/phpstan": "^2.1", "phpstan/phpstan": "^2.1",
"phpstan/phpstan-mockery": "^2.0",
"phpunit/phpunit": "^11.5.3" "phpunit/phpunit": "^11.5.3"
}, },
"autoload": { "autoload": {

View file

@ -33,13 +33,12 @@
'code' => 'belga', 'code' => 'belga',
'name' => 'Belga News Agency', 'name' => 'Belga News Agency',
'description' => 'Belgian national news agency', 'description' => 'Belgian national news agency',
'type' => 'website', 'type' => 'rss',
'is_active' => true, 'is_active' => true,
'languages' => [ 'languages' => [
'en' => ['url' => 'https://www.belganewsagency.eu/'], 'en' => ['url' => 'https://www.belganewsagency.eu/feed'],
], ],
'parsers' => [ 'parsers' => [
'homepage' => \App\Services\Parsers\BelgaHomepageParserAdapter::class,
'article' => \App\Services\Parsers\BelgaArticleParser::class, 'article' => \App\Services\Parsers\BelgaArticleParser::class,
'article_page' => \App\Services\Parsers\BelgaArticlePageParser::class, 'article_page' => \App\Services\Parsers\BelgaArticlePageParser::class,
], ],

View file

@ -2,8 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\ArticlePublication;
use App\Models\Article; use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;

View file

@ -75,7 +75,8 @@ public function belga(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'provider' => 'belga', 'provider' => 'belga',
'url' => 'https://www.belganewsagency.eu/', 'url' => 'https://www.belganewsagency.eu/feed',
'type' => 'rss',
]); ]);
} }
} }

View file

@ -17,7 +17,7 @@ public function definition(): array
{ {
return [ return [
'platform' => PlatformEnum::LEMMY, 'platform' => PlatformEnum::LEMMY,
'instance_url' => 'https://lemmy.' . $this->faker->domainName(), 'instance_url' => 'https://lemmy.'.$this->faker->domainName(),
'username' => $this->faker->userName(), 'username' => $this->faker->userName(),
'password' => 'test-password', 'password' => 'test-password',
'settings' => [], 'settings' => [],

View file

@ -34,7 +34,7 @@ public function inactive(): static
]); ]);
} }
public function community(string $name = null): static public function community(?string $name = null): static
{ {
$communityName = $name ?: $this->faker->word(); $communityName = $name ?: $this->faker->word();

View file

@ -33,8 +33,8 @@ public function lemmy(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'platform' => 'lemmy', 'platform' => 'lemmy',
'name' => 'Lemmy ' . $this->faker->word(), 'name' => 'Lemmy '.$this->faker->word(),
'url' => 'https://lemmy.' . $this->faker->domainName(), 'url' => 'https://lemmy.'.$this->faker->domainName(),
]); ]);
} }
} }

View file

@ -2,9 +2,9 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Route;
use App\Models\Feed; use App\Models\Feed;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\Route;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
class RouteFactory extends Factory class RouteFactory extends Factory

View file

@ -17,14 +17,13 @@ public function run(): void
'name' => 'Belgae Social', 'name' => 'Belgae Social',
'description' => 'A Belgian Lemmy instance on the fediverse', 'description' => 'A Belgian Lemmy instance on the fediverse',
], ],
])->each (fn ($instanceData) => ])->each(fn ($instanceData) => PlatformInstance::updateOrCreate(
PlatformInstance::updateOrCreate( [
[ 'platform' => $instanceData['platform'],
'platform' => $instanceData['platform'], 'url' => $instanceData['url'],
'url' => $instanceData['url'], ],
], $instanceData
$instanceData )
)
); );
} }
} }

View file

@ -3,7 +3,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Setting; use App\Models\Setting;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class SettingsSeeder extends Seeder class SettingsSeeder extends Seeder

109
phpstan-baseline.neon Normal file
View file

@ -0,0 +1,109 @@
parameters:
ignoreErrors:
-
message: '#^Call to an undefined static method App\\Models\\Feed\:\:withTrashed\(\)\.$#'
identifier: staticMethod.notFound
count: 1
path: tests/Feature/DatabaseIntegrationTest.php
-
message: '#^Access to an undefined property App\\Models\\PlatformAccount\:\:\$pivot\.$#'
identifier: property.notFound
count: 1
path: tests/Unit/Actions/CreateChannelActionTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with int will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Actions/CreateChannelActionTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with string will always evaluate to false\.$#'
identifier: method.impossibleType
count: 2
path: tests/Unit/Actions/CreateFeedActionTest.php
-
message: '#^Access to an undefined property App\\Models\\Route\:\:\$id\.$#'
identifier: property.notFound
count: 2
path: tests/Unit/Actions/CreateRouteActionTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertFalse\(\) with false will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Enums/LogLevelEnumTest.php
-
message: '#^Strict comparison using \=\=\= between App\\Enums\\LogLevelEnum\:\:DEBUG and App\\Enums\\LogLevelEnum\:\:DEBUG will always evaluate to true\.$#'
identifier: identical.alwaysTrue
count: 1
path: tests/Unit/Enums/LogLevelEnumTest.php
-
message: '#^Strict comparison using \=\=\= between App\\Enums\\LogLevelEnum\:\:DEBUG and App\\Enums\\LogLevelEnum\:\:INFO will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: tests/Unit/Enums/LogLevelEnumTest.php
-
message: '#^Strict comparison using \=\=\= between App\\Enums\\PlatformEnum\:\:LEMMY and App\\Enums\\PlatformEnum\:\:LEMMY will always evaluate to true\.$#'
identifier: identical.alwaysTrue
count: 1
path: tests/Unit/Enums/PlatformEnumTest.php
-
message: '#^Access to an undefined property App\\Models\\PlatformChannel\:\:\$pivot\.$#'
identifier: property.notFound
count: 2
path: tests/Unit/Models/FeedTest.php
-
message: '#^Access to an undefined property App\\Models\\PlatformChannel\:\:\$pivot\.$#'
identifier: property.notFound
count: 6
path: tests/Unit/Models/PlatformAccountTest.php
-
message: '#^Access to an undefined property App\\Models\\Feed\:\:\$pivot\.$#'
identifier: property.notFound
count: 2
path: tests/Unit/Models/PlatformChannelTest.php
-
message: '#^Access to an undefined property App\\Models\\PlatformAccount\:\:\$pivot\.$#'
identifier: property.notFound
count: 6
path: tests/Unit/Models/PlatformChannelTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with int will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Models/PlatformChannelTest.php
-
message: '#^Access to an undefined property App\\Models\\Language\:\:\$pivot\.$#'
identifier: property.notFound
count: 7
path: tests/Unit/Models/PlatformInstanceTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with string will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Models/PlatformInstanceTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with string will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Models/RouteTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with int will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Services/ArticleFetcherTest.php

View file

@ -1,5 +1,7 @@
includes: includes:
- vendor/larastan/larastan/extension.neon - vendor/larastan/larastan/extension.neon
- vendor/phpstan/phpstan-mockery/extension.neon
- phpstan-baseline.neon
parameters: parameters:
level: 7 level: 7
@ -10,3 +12,7 @@ parameters:
excludePaths: excludePaths:
- bootstrap/*.php - bootstrap/*.php
- storage/* - storage/*
ignoreErrors:
- identifier: method.alreadyNarrowedType
- identifier: function.alreadyNarrowedType

View file

@ -47,7 +47,7 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font
<span>&bull;</span> <span>&bull;</span>
<span>Feed: {{ $route->feed?->name }}</span> <span>Feed: {{ $route->feed?->name }}</span>
<span>&bull;</span> <span>&bull;</span>
<span>Channel: {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }}</span> <span>{{ $route->platformChannel?->platformInstance?->platform?->channelLabel() ?? 'Channel' }}: {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }}</span>
<span>&bull;</span> <span>&bull;</span>
<span>Created: {{ $route->created_at->format('M d, Y') }}</span> <span>Created: {{ $route->created_at->format('M d, Y') }}</span>
</div> </div>
@ -246,7 +246,7 @@ class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transp
<strong>Feed:</strong> {{ $editingRoute->feed?->name }} <strong>Feed:</strong> {{ $editingRoute->feed?->name }}
</p> </p>
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
<strong>Channel:</strong> {{ $editingRoute->platformChannel?->display_name ?? $editingRoute->platformChannel?->name }} <strong>{{ $editingRoute->platformChannel?->platformInstance?->platform?->channelLabel() ?? 'Channel' }}:</strong> {{ $editingRoute->platformChannel?->display_name ?? $editingRoute->platformChannel?->name }}
</p> </p>
</div> </div>

Some files were not shown because too many files have changed in this diff Show more