Compare commits

..

34 commits
v1.1.0 ... main

Author SHA1 Message Date
4b0d17e2ae Fix flaky boundary test with freezeTime
All checks were successful
CI / ci (push) Successful in 5m36s
CI / ci (pull_request) Successful in 16m47s
Build and Push Docker Image / build (push) Successful in 4m15s
2026-03-18 21:54:20 +01:00
ff855facda Fix flaky test from duplicate Article factory URLs
Some checks failed
CI / ci (push) Successful in 5m14s
CI / ci (pull_request) Failing after 5m13s
2026-03-18 21:39:22 +01:00
a7317e376f 103 - Move notification bell to page header component
Some checks failed
CI / ci (push) Failing after 6m32s
CI / ci (pull_request) Successful in 5m35s
2026-03-18 21:27:14 +01:00
3d58baf156 102 - Fix route modals hidden behind overlay 2026-03-18 20:58:35 +01:00
268bbfd05a 101 - Fix cramped number inputs on Settings page 2026-03-18 20:52:49 +01:00
d2919758f5 Fix Pint 1.29.0 lint issues and update CI workflow
All checks were successful
CI / ci (push) Successful in 5m52s
CI / ci (pull_request) Successful in 5m46s
Build and Push Docker Image / build (push) Successful in 4m6s
2026-03-18 20:01:25 +01:00
dfb87f4152 Debug CI Pint environment
Some checks failed
CI / ci (push) Failing after 4m4s
CI / ci (pull_request) Failing after 4m4s
2026-03-18 19:48:48 +01:00
273fca76e3 Fix CI lint by using ZTS PHP to match dev container
Some checks failed
CI / ci (push) Failing after 4m37s
CI / ci (pull_request) Failing after 4m37s
2026-03-18 18:42:41 +01:00
cc94ba8e55 89 - Add article cleanup job with 30-day retention policy
Some checks failed
CI / ci (push) Failing after 4m35s
CI / ci (pull_request) Failing after 4m23s
2026-03-18 18:09:54 +01:00
bab2557e85 97 - Add auto_approve toggle to Route edit modal 2026-03-18 17:51:35 +01:00
9430158051 96 - Rework Articles page into route_article triage UI with tabs and actions 2026-03-18 17:39:38 +01:00
e7acbb6882 99 - Move publish_status from Article to RouteArticle with PublishStatusEnum 2026-03-18 17:31:47 +01:00
9fb373d139 98 - Add RouteArticle API endpoints for approve, reject, restore, and clear 2026-03-18 17:20:15 +01:00
2b74f24356 85 - Replace ArticleApproved with RouteArticleApproved event and update publishing listener 2026-03-18 17:00:56 +01:00
5e571babda 85 - Fix keyword matching to include title and description, add PHPStan type annotations 2026-03-18 16:35:12 +01:00
f3406b1713 85 - Remove approval_status from Article, migrate to route_articles 2026-03-18 16:23:46 +01:00
d0985fc57d 85 - Simplify Articles page to read-only feed log 2026-03-18 16:09:48 +01:00
0c35af4403 85 - Update publishing pipeline to use route_articles for per-route publishing 2026-03-18 16:05:31 +01:00
f449548123 85 - Update ValidateArticleListener for per-route validation flow 2026-03-18 15:51:33 +01:00
e3ea02ae1c 85 - Refactor ValidationService to per-route keyword evaluation with ApprovalStatusEnum 2026-03-18 15:46:15 +01:00
2a5a8c788b 85 - Add auto_approve column to routes table 2026-03-18 15:28:01 +01:00
b832d6d850 85 - Add route_articles table, model, and factory for per-route approval 2026-03-18 15:24:03 +01:00
d21c054250 94 - Add publishing failure notifications to job and listener
Some checks failed
CI / ci (push) Failing after 5m7s
2026-03-09 21:20:41 +01:00
062b00d01c 93 - Add feed staleness detection with configurable threshold and alerts 2026-03-09 17:32:55 +01:00
4feab96765 92 - Add in-app notification infrastructure with model, service, and bell component 2026-03-09 02:21:24 +01:00
ec09711a6f 21 - Add dynamic channel labels based on platform type
All checks were successful
CI / ci (push) Successful in 5m21s
CI / ci (pull_request) Successful in 5m39s
Build and Push Docker Image / build (push) Successful in 4m36s
2026-03-08 18:10:47 +01:00
bf96489362 50 - Add ActionPerformed event and LogActionListener for centralized DB logging 2026-03-08 17:53:43 +01:00
0bb10729de 78 - Convert Belga from website scraping to RSS feed parsing 2026-03-08 17:19:11 +01:00
19cbea9273 25 - Disable Vite in tests to fix CI manifest errors
All checks were successful
CI / ci (push) Successful in 5m24s
2026-03-08 14:58:00 +01:00
a448c54e73 25 - Fix PHPStan baseline discrepancy between local and CI
Some checks failed
CI / ci (push) Failing after 5m37s
2026-03-08 14:45:24 +01:00
675802e8e5 25 - Move PHPStan ignores to baseline, fix HttpFetcher type
Some checks failed
CI / ci (push) Failing after 4m42s
2026-03-08 14:33:54 +01:00
6784af2ff6 25 - Fix all PHPStan errors and add mockery extension
Some checks failed
CI / ci (push) Failing after 4m31s
2026-03-08 14:18:28 +01:00
56db303b15 25 - Fix code style issues across codebase with Pint 2026-03-08 14:17:55 +01:00
0b2fc5004b 25 - Add code coverage PR comment to CI workflow
Some checks failed
CI / ci (push) Failing after 4m11s
2026-03-08 12:58:01 +01:00
228 changed files with 5788 additions and 3281 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

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

@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum ApprovalStatusEnum: string
{
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum NotificationSeverityEnum: string
{
case INFO = 'info';
case WARNING = 'warning';
case ERROR = 'error';
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum NotificationTypeEnum: string
{
case GENERAL = 'general';
case FEED_STALE = 'feed_stale';
case PUBLISH_FAILED = 'publish_failed';
case CREDENTIAL_EXPIRED = 'credential_expired';
public function label(): string
{
return match ($this) {
self::GENERAL => 'General',
self::FEED_STALE => 'Feed Stale',
self::PUBLISH_FAILED => 'Publish Failed',
self::CREDENTIAL_EXPIRED => 'Credential Expired',
};
}
}

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,11 @@
<?php
namespace App\Enums;
enum PublishStatusEnum: string
{
case UNPUBLISHED = 'unpublished';
case PUBLISHING = 'publishing';
case PUBLISHED = 'published';
case ERROR = 'error';
}

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

@ -2,16 +2,13 @@
namespace App\Events; namespace App\Events;
use App\Models\Article; use App\Models\RouteArticle;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class ArticleApproved class RouteArticleApproved
{ {
use Dispatchable, SerializesModels; use Dispatchable, SerializesModels;
public function __construct(public Article $article) public function __construct(public RouteArticle $routeArticle) {}
{
//
}
} }

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
{ {
@ -41,40 +40,6 @@ public function index(Request $request): JsonResponse
]); ]);
} }
/**
* Approve an article
*/
public function approve(Article $article): JsonResponse
{
try {
$article->approve('manual');
return $this->sendResponse(
new ArticleResource($article->fresh(['feed', 'articlePublication'])),
'Article approved and queued for publishing.'
);
} catch (Exception $e) {
return $this->sendError('Failed to approve article: ' . $e->getMessage(), [], 500);
}
}
/**
* Reject an article
*/
public function reject(Article $article): JsonResponse
{
try {
$article->reject('manual');
return $this->sendResponse(
new ArticleResource($article->fresh(['feed', 'articlePublication'])),
'Article rejected.'
);
} catch (Exception $e) {
return $this->sendError('Failed to reject article: ' . $e->getMessage(), [], 500);
}
}
/** /**
* Manually refresh articles from all active feeds * Manually refresh articles from all active feeds
*/ */

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;

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
{ {
@ -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,7 +37,6 @@ 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.');
} }

View file

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

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

View file

@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Enums\ApprovalStatusEnum;
use App\Http\Resources\RouteArticleResource;
use App\Models\RouteArticle;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RouteArticlesController extends BaseController
{
public function index(Request $request): JsonResponse
{
$perPage = min($request->get('per_page', 15), 100);
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
->orderBy('created_at', 'desc');
if ($request->has('status')) {
$status = ApprovalStatusEnum::tryFrom($request->get('status'));
if ($status) {
$query->where('approval_status', $status);
}
}
$routeArticles = $query->paginate($perPage);
return $this->sendResponse([
'route_articles' => RouteArticleResource::collection($routeArticles->items()),
'pagination' => [
'current_page' => $routeArticles->currentPage(),
'last_page' => $routeArticles->lastPage(),
'per_page' => $routeArticles->perPage(),
'total' => $routeArticles->total(),
'from' => $routeArticles->firstItem(),
'to' => $routeArticles->lastItem(),
],
]);
}
public function approve(RouteArticle $routeArticle): JsonResponse
{
try {
$routeArticle->approve();
return $this->sendResponse(
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
'Route article approved and queued for publishing.'
);
} catch (Exception $e) {
return $this->sendError('Failed to approve route article: '.$e->getMessage(), [], 500);
}
}
public function reject(RouteArticle $routeArticle): JsonResponse
{
try {
$routeArticle->reject();
return $this->sendResponse(
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
'Route article rejected.'
);
} catch (Exception $e) {
return $this->sendError('Failed to reject route article: '.$e->getMessage(), [], 500);
}
}
public function restore(RouteArticle $routeArticle): JsonResponse
{
try {
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
return $this->sendResponse(
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
'Route article restored to pending.'
);
} catch (Exception $e) {
return $this->sendError('Failed to restore route article: '.$e->getMessage(), [], 500);
}
}
public function clear(): JsonResponse
{
try {
$count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
return $this->sendResponse(
['rejected_count' => $count],
"Rejected {$count} pending route articles."
);
} catch (Exception $e) {
return $this->sendError('Failed to clear pending route articles: '.$e->getMessage(), [], 500);
}
}
}

View file

@ -11,6 +11,7 @@
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rules; use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View; use Illuminate\View\View;
class NewPasswordController extends Controller class NewPasswordController extends Controller
@ -26,7 +27,7 @@ public function create(Request $request): View
/** /**
* Handle an incoming new password request. * Handle an incoming new password request.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {

View file

@ -6,6 +6,7 @@
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View; use Illuminate\View\View;
class PasswordResetLinkController extends Controller class PasswordResetLinkController extends Controller
@ -21,7 +22,7 @@ public function create(): View
/** /**
* Handle an incoming password reset link request. * Handle an incoming password reset link request.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {

View file

@ -10,6 +10,7 @@
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules; use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View; use Illuminate\View\View;
class RegisteredUserController extends Controller class RegisteredUserController extends Controller
@ -25,7 +26,7 @@ public function create(): View
/** /**
* Handle an incoming registration request. * Handle an incoming registration request.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {

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

@ -12,7 +12,7 @@ class HandleAppearance
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next * @param Closure(Request): (Response) $next
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {

View file

@ -3,6 +3,7 @@
namespace App\Http\Requests\Auth; namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout; use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@ -22,7 +23,7 @@ public function authorize(): bool
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string> * @return array<string, ValidationRule|array<mixed>|string>
*/ */
public function rules(): array public function rules(): array
{ {
@ -35,7 +36,7 @@ public function rules(): array
/** /**
* Attempt to authenticate the request's credentials. * Attempt to authenticate the request's credentials.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function authenticate(): void public function authenticate(): void
{ {
@ -55,7 +56,7 @@ public function authenticate(): void
/** /**
* Ensure the login request is not rate limited. * Ensure the login request is not rate limited.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function ensureIsNotRateLimited(): void public function ensureIsNotRateLimited(): void
{ {

View file

@ -3,6 +3,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -11,7 +12,7 @@ class ProfileUpdateRequest extends FormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string> * @return array<string, ValidationRule|array<mixed>|string>
*/ */
public function rules(): array public function rules(): array
{ {

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

@ -23,7 +23,7 @@ public function rules(): array
'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

@ -2,9 +2,13 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Models\ArticlePublication;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin ArticlePublication
*/
class ArticlePublicationResource extends JsonResource class ArticlePublicationResource extends JsonResource
{ {
/** /**
@ -17,8 +21,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

@ -2,14 +2,18 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Models\Article;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/** /**
* @property int $id * @mixin 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 [
@ -18,13 +22,6 @@ public function toArray(Request $request): array
'url' => $this->url, 'url' => $this->url,
'title' => $this->title, 'title' => $this->title,
'description' => $this->description, 'description' => $this->description,
'is_valid' => $this->is_valid,
'is_duplicate' => $this->is_duplicate,
'approval_status' => $this->approval_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

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

View file

@ -2,9 +2,16 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Models\PlatformAccount;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin PlatformAccount
*/
/**
* @mixin PlatformAccount
*/
class PlatformAccountResource extends JsonResource class PlatformAccountResource extends JsonResource
{ {
/** /**

View file

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

View file

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

View file

@ -0,0 +1,39 @@
<?php
namespace App\Http\Resources;
use App\Models\RouteArticle;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin RouteArticle
*/
class RouteArticleResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'feed_id' => $this->feed_id,
'platform_channel_id' => $this->platform_channel_id,
'article_id' => $this->article_id,
'approval_status' => $this->approval_status->value,
'publish_status' => $this->publish_status->value,
'validated_at' => $this->validated_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
'article' => [
'id' => $this->article->id,
'title' => $this->article->title,
'url' => $this->article->url,
'description' => $this->article->description,
'feed_name' => $this->article->feed->name,
],
'route_name' => $this->feed->name.' → '.$this->platformChannel->name,
];
}
}

View file

@ -2,9 +2,13 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Models\Route;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Route
*/
class RouteResource extends JsonResource class RouteResource extends JsonResource
{ {
/** /**
@ -15,7 +19,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

@ -0,0 +1,51 @@
<?php
namespace App\Jobs;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Models\Feed;
use App\Models\Notification;
use App\Models\Setting;
use App\Services\Notification\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class CheckFeedStalenessJob implements ShouldQueue
{
use Queueable;
public function handle(NotificationService $notificationService): void
{
$thresholdHours = Setting::getFeedStalenessThreshold();
if ($thresholdHours === 0) {
return;
}
$staleFeeds = Feed::stale($thresholdHours)->get();
foreach ($staleFeeds as $feed) {
$alreadyNotified = Notification::query()
->where('type', NotificationTypeEnum::FEED_STALE)
->where('notifiable_type', $feed->getMorphClass())
->where('notifiable_id', $feed->getKey())
->unread()
->exists();
if ($alreadyNotified) {
continue;
}
$notificationService->send(
type: NotificationTypeEnum::FEED_STALE,
severity: NotificationSeverityEnum::WARNING,
title: "Feed \"{$feed->name}\" is stale",
message: $feed->last_fetched_at
? "Last fetched {$feed->last_fetched_at->diffForHumans()}. Threshold is {$thresholdHours} hours."
: "This feed has never been fetched. Threshold is {$thresholdHours} hours.",
notifiable: $feed,
);
}
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Jobs;
use App\Enums\ApprovalStatusEnum;
use App\Models\Article;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class CleanupArticlesJob implements ShouldQueue
{
use Queueable;
private const RETENTION_DAYS = 30;
public function handle(): void
{
Article::where('created_at', '<', now()->subDays(self::RETENTION_DAYS))
->whereDoesntHave('routeArticles', fn ($q) => $q->whereIn('approval_status', [
ApprovalStatusEnum::PENDING,
ApprovalStatusEnum::APPROVED,
]))
->delete();
}
}

View file

@ -2,17 +2,24 @@
namespace App\Jobs; namespace App\Jobs;
use App\Enums\ApprovalStatusEnum;
use App\Enums\LogLevelEnum;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Enums\PublishStatusEnum;
use App\Events\ActionPerformed;
use App\Exceptions\PublishException; use App\Exceptions\PublishException;
use App\Models\Article;
use App\Models\ArticlePublication; use App\Models\ArticlePublication;
use App\Models\RouteArticle;
use App\Models\Setting; use App\Models\Setting;
use App\Services\Article\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUnique;
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,9 +35,10 @@ 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, NotificationService $notificationService): void
{ {
$interval = Setting::getArticlePublishingInterval(); $interval = Setting::getArticlePublishingInterval();
@ -42,39 +50,73 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
} }
} }
// Get the oldest approved article that hasn't been published yet // Get the oldest approved route_article that hasn't been published to its channel yet
$article = Article::where('approval_status', 'approved') $routeArticle = RouteArticle::where('approval_status', ApprovalStatusEnum::APPROVED)
->whereDoesntHave('articlePublication') ->whereDoesntHave('article.articlePublications', function ($query) {
->oldest('created_at') $query->whereColumn('article_publications.platform_channel_id', 'route_articles.platform_channel_id');
})
->oldest('route_articles.created_at')
->with(['article', 'platformChannel.platformInstance', 'platformChannel.activePlatformAccounts'])
->first(); ->first();
if (! $article) { if (! $routeArticle) {
return; return;
} }
logger()->info('Publishing next article from scheduled job', [ $article = $routeArticle->article;
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 'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id,
]); ]);
// Fetch article data $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
$extractedData = $articleFetcher->fetchArticleData($article);
try { try {
$publishingService->publishToRoutedChannels($article, $extractedData); $extractedData = $articleFetcher->fetchArticleData($article);
$publication = $publishingService->publishRouteArticle($routeArticle, $extractedData);
logger()->info('Successfully published article', [ if ($publication) {
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title 'title' => $article->title,
]); ]);
} else {
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [
'article_id' => $article->id,
'title' => $article->title,
]);
$notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::WARNING,
"Publish failed: {$article->title}",
'No publication was created for this article. Check channel routing configuration.',
$article,
);
}
} catch (PublishException $e) { } catch (PublishException $e) {
logger()->error('Failed to publish article', [ $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
$notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::ERROR,
"Publish failed: {$article->title}",
$e->getMessage(),
$article,
);
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;

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,9 +5,9 @@
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
@ -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,8 +2,14 @@
namespace App\Listeners; namespace App\Listeners;
use App\Events\ArticleApproved; use App\Enums\LogLevelEnum;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Enums\PublishStatusEnum;
use App\Events\ActionPerformed;
use App\Events\RouteArticleApproved;
use App\Services\Article\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
use Exception; use Exception;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -14,51 +20,67 @@ class PublishApprovedArticleListener implements ShouldQueue
public function __construct( public function __construct(
private ArticleFetcher $articleFetcher, private ArticleFetcher $articleFetcher,
private ArticlePublishingService $publishingService private ArticlePublishingService $publishingService,
private NotificationService $notificationService,
) {} ) {}
public function handle(ArticleApproved $event): void public function handle(RouteArticleApproved $event): void
{ {
$article = $event->article->fresh(); $routeArticle = $event->routeArticle;
$article = $routeArticle->article;
// Skip if already published // Skip if already published to this channel
if ($article->articlePublication()->exists()) { if ($article->articlePublications()
->where('platform_channel_id', $routeArticle->platform_channel_id)
->exists()
) {
return; return;
} }
// Skip if not approved (safety check) $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
if (! $article->isApproved()) {
return;
}
$article->update(['publish_status' => 'publishing']);
try { try {
$extractedData = $this->articleFetcher->fetchArticleData($article); $extractedData = $this->articleFetcher->fetchArticleData($article);
$publications = $this->publishingService->publishToRoutedChannels($article, $extractedData); $publication = $this->publishingService->publishRouteArticle($routeArticle, $extractedData);
if ($publications->isNotEmpty()) { if ($publication) {
$article->update(['publish_status' => 'published']); $routeArticle->update(['publish_status' => PublishStatusEnum::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']); $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
logger()->warning('No publications created for approved article', [ ActionPerformed::dispatch('No publication created for approved article', LogLevelEnum::WARNING, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title, 'title' => $article->title,
]); ]);
$this->notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::WARNING,
"Publish failed: {$article->title}",
'No publication was created for this article. Check channel routing configuration.',
$article,
);
} }
} catch (Exception $e) { } catch (Exception $e) {
$article->update(['publish_status' => 'error']); $routeArticle->update(['publish_status' => PublishStatusEnum::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(),
]); ]);
$this->notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::ERROR,
"Publish failed: {$article->title}",
$e->getMessage(),
$article,
);
} }
} }
} }

View file

@ -2,8 +2,9 @@
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\Services\Article\ValidationService; use App\Services\Article\ValidationService;
use Exception; use Exception;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -24,37 +25,18 @@ public function handle(NewArticleFetched $event): void
return; return;
} }
// Only validate articles that are still pending
if (! $article->isPending()) {
return;
}
// Skip if already has publication (prevents duplicate processing) // Skip if already has publication (prevents duplicate processing)
if ($article->articlePublication()->exists()) { if ($article->articlePublication()->exists()) {
return; return;
} }
try { try {
$article = $this->validationService->validate($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;
}
if ($article->isValid()) {
// Double-check publication doesn't exist (race condition protection)
if ($article->articlePublication()->exists()) {
return;
}
// If approvals are enabled, article waits for manual approval.
// If approvals are disabled, auto-approve and publish.
if (! Setting::isPublishingApprovalsEnabled()) {
$article->approve();
}
} }
} }
} }

View file

@ -2,9 +2,10 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Article; use App\Enums\ApprovalStatusEnum;
use App\Models\Setting;
use App\Jobs\ArticleDiscoveryJob; use App\Jobs\ArticleDiscoveryJob;
use App\Models\RouteArticle;
use Illuminate\Contracts\View\View;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@ -12,22 +13,44 @@ class Articles extends Component
{ {
use WithPagination; use WithPagination;
public string $tab = 'pending';
public string $search = '';
public bool $isRefreshing = false; public bool $isRefreshing = false;
public function approve(int $articleId): void public function setTab(string $tab): void
{ {
$article = Article::findOrFail($articleId); $this->tab = $tab;
$article->approve(); $this->search = '';
$this->resetPage();
$this->dispatch('article-updated');
} }
public function reject(int $articleId): void public function updatedSearch(): void
{ {
$article = Article::findOrFail($articleId); $this->resetPage();
$article->reject(); }
$this->dispatch('article-updated'); public function approve(int $routeArticleId): void
{
RouteArticle::findOrFail($routeArticleId)->approve();
}
public function reject(int $routeArticleId): void
{
RouteArticle::findOrFail($routeArticleId)->reject();
}
public function restore(int $routeArticleId): void
{
$routeArticle = RouteArticle::findOrFail($routeArticleId);
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
}
public function clear(): void
{
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
} }
public function refresh(): void public function refresh(): void
@ -39,17 +62,28 @@ public function refresh(): void
$this->dispatch('refresh-started'); $this->dispatch('refresh-started');
} }
public function render() public function render(): View
{ {
$articles = Article::with(['feed', 'articlePublication']) $query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc');
->paginate(15);
$approvalsEnabled = Setting::isPublishingApprovalsEnabled(); if ($this->tab === 'pending') {
$query->where('approval_status', ApprovalStatusEnum::PENDING);
} elseif ($this->search !== '') {
$search = $this->search;
$query->whereHas('article', function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
$routeArticles = $query->paginate(15);
$pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
return view('livewire.articles', [ return view('livewire.articles', [
'articles' => $articles, 'routeArticles' => $routeArticles,
'approvalsEnabled' => $approvalsEnabled, 'pendingCount' => $pendingCount,
])->layout('layouts.app'); ])->layout('layouts.app');
} }
} }

View file

@ -4,6 +4,7 @@
use App\Models\PlatformAccount; use App\Models\PlatformAccount;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use Illuminate\Contracts\View\View;
use Livewire\Component; use Livewire\Component;
class Channels extends Component class Channels extends Component
@ -51,7 +52,7 @@ public function detachAccount(int $channelId, int $accountId): void
$channel->platformAccounts()->detach($accountId); $channel->platformAccounts()->detach($accountId);
} }
public function render() public function render(): 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();

View file

@ -3,6 +3,7 @@
namespace App\Livewire; namespace App\Livewire;
use App\Services\DashboardStatsService; use App\Services\DashboardStatsService;
use Illuminate\Contracts\View\View;
use Livewire\Component; use Livewire\Component;
class Dashboard extends Component class Dashboard extends Component
@ -19,7 +20,7 @@ public function setPeriod(string $period): void
$this->period = $period; $this->period = $period;
} }
public function render() public function render(): View
{ {
$service = app(DashboardStatsService::class); $service = app(DashboardStatsService::class);

View file

@ -3,6 +3,7 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Feed; use App\Models\Feed;
use Illuminate\Contracts\View\View;
use Livewire\Component; use Livewire\Component;
class Feeds extends Component class Feeds extends Component
@ -14,7 +15,7 @@ public function toggle(int $feedId): void
$feed->save(); $feed->save();
} }
public function render() public function render(): View
{ {
$feeds = Feed::orderBy('name')->get(); $feeds = Feed::orderBy('name')->get();

View file

@ -0,0 +1,42 @@
<?php
namespace App\Livewire;
use App\Models\Notification;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Computed;
use Livewire\Component;
class NotificationBell extends Component
{
public function markAsRead(int $id): void
{
Notification::findOrFail($id)->markAsRead();
}
public function markAllAsRead(): void
{
Notification::markAllAsRead();
}
#[Computed]
public function unreadCount(): int
{
return Notification::unread()->count();
}
/**
* @return Collection<int, Notification>
*/
#[Computed]
public function notifications(): Collection
{
return Notification::recent()->get();
}
public function render(): View
{
return view('livewire.notification-bell');
}
}

View file

@ -18,7 +18,9 @@
use App\Models\Setting; use App\Models\Setting;
use App\Services\OnboardingService; use App\Services\OnboardingService;
use Exception; use Exception;
use Illuminate\Contracts\View\View;
use InvalidArgumentException; use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
use RuntimeException; use RuntimeException;
@ -29,36 +31,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]
#[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(
@ -319,6 +339,9 @@ 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', []);
@ -329,7 +352,7 @@ public function getAvailableLanguageCodes(): array
continue; continue;
} }
foreach (array_keys($provider['languages'] ?? []) as $code) { foreach (array_keys($provider['languages'] ?? []) as $code) {
$languageCodes[$code] = true; $languageCodes[(string) $code] = true;
} }
} }
@ -339,6 +362,9 @@ 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) {
@ -378,10 +404,11 @@ 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(): 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

@ -6,24 +6,32 @@
use App\Models\Keyword; use App\Models\Keyword;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\Route; use App\Models\Route;
use Illuminate\Contracts\View\View;
use Livewire\Component; use Livewire\Component;
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
public int $editPriority = 50; public int $editPriority = 50;
public string $editAutoApprove = '';
// 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 +61,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;
} }
@ -75,6 +84,7 @@ public function openEditModal(int $feedId, int $channelId): void
$this->editingFeedId = $feedId; $this->editingFeedId = $feedId;
$this->editingChannelId = $channelId; $this->editingChannelId = $channelId;
$this->editPriority = $route->priority; $this->editPriority = $route->priority;
$this->editAutoApprove = $route->auto_approve === null ? '' : ($route->auto_approve ? '1' : '0');
$this->newKeyword = ''; $this->newKeyword = '';
$this->showKeywordInput = false; $this->showKeywordInput = false;
} }
@ -95,9 +105,18 @@ public function updateRoute(): void
'editPriority' => 'required|integer|min:0', 'editPriority' => 'required|integer|min:0',
]); ]);
$autoApprove = match ($this->editAutoApprove) {
'1' => true,
'0' => false,
default => null,
};
Route::where('feed_id', $this->editingFeedId) Route::where('feed_id', $this->editingFeedId)
->where('platform_channel_id', $this->editingChannelId) ->where('platform_channel_id', $this->editingChannelId)
->update(['priority' => $this->editPriority]); ->update([
'priority' => $this->editPriority,
'auto_approve' => $autoApprove,
]);
$this->closeEditModal(); $this->closeEditModal();
} }
@ -153,9 +172,9 @@ public function deleteKeyword(int $keywordId): void
Keyword::destroy($keywordId); Keyword::destroy($keywordId);
} }
public function render() public function render(): View
{ {
$routes = Route::with(['feed', 'platformChannel']) $routes = Route::with(['feed', 'platformChannel.platformInstance'])
->orderBy('priority', 'desc') ->orderBy('priority', 'desc')
->get(); ->get();
@ -168,7 +187,8 @@ public function render()
$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 +199,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

@ -3,15 +3,21 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Setting; use App\Models\Setting;
use Illuminate\Contracts\View\View;
use Livewire\Component; use Livewire\Component;
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 int $feedStalenessThreshold = 48;
public ?string $successMessage = null; public ?string $successMessage = null;
public ?string $errorMessage = null; public ?string $errorMessage = null;
public function mount(): void public function mount(): void
@ -19,6 +25,7 @@ public function mount(): void
$this->articleProcessingEnabled = Setting::isArticleProcessingEnabled(); $this->articleProcessingEnabled = Setting::isArticleProcessingEnabled();
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled(); $this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
$this->articlePublishingInterval = Setting::getArticlePublishingInterval(); $this->articlePublishingInterval = Setting::getArticlePublishingInterval();
$this->feedStalenessThreshold = Setting::getFeedStalenessThreshold();
} }
public function toggleArticleProcessing(): void public function toggleArticleProcessing(): void
@ -45,6 +52,16 @@ public function updateArticlePublishingInterval(): void
$this->showSuccess(); $this->showSuccess();
} }
public function updateFeedStalenessThreshold(): void
{
$this->validate([
'feedStalenessThreshold' => 'required|integer|min:0',
]);
Setting::setFeedStalenessThreshold($this->feedStalenessThreshold);
$this->showSuccess();
}
protected function showSuccess(): void protected function showSuccess(): void
{ {
$this->successMessage = 'Settings updated successfully!'; $this->successMessage = 'Settings updated successfully!';
@ -60,7 +77,7 @@ public function clearMessages(): void
$this->errorMessage = null; $this->errorMessage = null;
} }
public function render() public function render(): View
{ {
return view('livewire.settings')->layout('layouts.app'); return view('livewire.settings')->layout('layouts.app');
} }

View file

@ -2,12 +2,12 @@
namespace App\Models; namespace App\Models;
use App\Events\ArticleApproved;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use Database\Factories\ArticleFactory; use Database\Factories\ArticleFactory;
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;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@ -15,15 +15,17 @@
* @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 bool|null $is_valid * @property string $title
* @property string|null $description
* @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
{ {
@ -39,9 +41,7 @@ class Article extends Model
'image_url', 'image_url',
'published_at', 'published_at',
'author', 'author',
'approval_status',
'validated_at', 'validated_at',
'publish_status',
]; ];
/** /**
@ -50,8 +50,6 @@ class Article extends Model
public function casts(): array public function casts(): array
{ {
return [ return [
'approval_status' => 'string',
'publish_status' => 'string',
'published_at' => 'datetime', 'published_at' => 'datetime',
'validated_at' => 'datetime', 'validated_at' => 'datetime',
'created_at' => 'datetime', 'created_at' => 'datetime',
@ -59,58 +57,6 @@ public function casts(): array
]; ];
} }
public function isValid(): bool
{
return $this->validated_at !== null && ! $this->isRejected();
}
public function isApproved(): bool
{
return $this->approval_status === 'approved';
}
public function isPending(): bool
{
return $this->approval_status === 'pending';
}
public function isRejected(): bool
{
return $this->approval_status === 'rejected';
}
public function approve(string $approvedBy = null): void
{
$this->update([
'approval_status' => 'approved',
]);
// Fire event to trigger publishing
event(new ArticleApproved($this));
}
public function reject(string $rejectedBy = null): void
{
$this->update([
'approval_status' => 'rejected',
]);
}
public function canBePublished(): bool
{
if (!$this->isValid()) {
return false;
}
// If approval system is disabled, auto-approve valid articles
if (!\App\Models\Setting::isPublishingApprovalsEnabled()) {
return true;
}
// If approval system is enabled, only approved articles can be published
return $this->isApproved();
}
public function getIsPublishedAttribute(): bool public function getIsPublishedAttribute(): bool
{ {
return $this->articlePublication()->exists(); return $this->articlePublication()->exists();
@ -124,6 +70,14 @@ public function articlePublication(): HasOne
return $this->hasOne(ArticlePublication::class); return $this->hasOne(ArticlePublication::class);
} }
/**
* @return HasMany<ArticlePublication, $this>
*/
public function articlePublications(): HasMany
{
return $this->hasMany(ArticlePublication::class);
}
/** /**
* @return BelongsTo<Feed, $this> * @return BelongsTo<Feed, $this>
*/ */
@ -132,6 +86,14 @@ public function feed(): BelongsTo
return $this->belongsTo(Feed::class); return $this->belongsTo(Feed::class);
} }
/**
* @return HasMany<RouteArticle, $this>
*/
public function routeArticles(): HasMany
{
return $this->hasMany(RouteArticle::class);
}
public function dispatchFetchedEvent(): void public function dispatchFetchedEvent(): void
{ {
event(new NewArticleFetched($this)); event(new NewArticleFetched($this));

View file

@ -6,11 +6,19 @@
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;
use Illuminate\Support\Carbon;
/** /**
* @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 Carbon $published_at
* @property Carbon $created_at
* @property Carbon $updated_at
* *
* @method static create(array<string, mixed> $array) * @method static create(array<string, mixed> $array)
*/ */

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use Database\Factories\FeedFactory; use Database\Factories\FeedFactory;
use Illuminate\Database\Eloquent\Builder;
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;
@ -16,7 +17,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 +25,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 +34,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 +48,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
@ -79,12 +83,25 @@ 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> * @param Builder<Feed> $query
* @return Builder<Feed>
*/
public function scopeStale(Builder $query, int $thresholdHours): Builder
{
return $query->where('is_active', true)
->where(function (Builder $query) use ($thresholdHours) {
$query->whereNull('last_fetched_at')
->orWhere('last_fetched_at', '<', now()->subHours($thresholdHours));
});
}
/**
* @return BelongsToMany<PlatformChannel, $this>
*/ */
public function channels(): BelongsToMany public function channels(): BelongsToMany
{ {
@ -94,7 +111,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

@ -0,0 +1,92 @@
<?php
namespace App\Models;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use Database\Factories\NotificationFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property NotificationTypeEnum $type
* @property NotificationSeverityEnum $severity
* @property string $title
* @property string $message
* @property array<string, mixed>|null $data
* @property string|null $notifiable_type
* @property int|null $notifiable_id
* @property Carbon|null $read_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Notification extends Model
{
/** @use HasFactory<NotificationFactory> */
use HasFactory;
protected $fillable = [
'type',
'severity',
'title',
'message',
'data',
'notifiable_type',
'notifiable_id',
'read_at',
];
protected $casts = [
'type' => NotificationTypeEnum::class,
'severity' => NotificationSeverityEnum::class,
'data' => 'array',
'read_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* @return MorphTo<Model, $this>
*/
public function notifiable(): MorphTo
{
return $this->morphTo();
}
public function isRead(): bool
{
return $this->read_at !== null;
}
public function markAsRead(): void
{
$this->update(['read_at' => now()]);
}
/**
* @param Builder<Notification> $query
* @return Builder<Notification>
*/
public function scopeUnread(Builder $query): Builder
{
return $query->whereNull('read_at');
}
/**
* @param Builder<Notification> $query
* @return Builder<Notification>
*/
public function scopeRecent(Builder $query): Builder
{
return $query->latest()->limit(50);
}
public static function markAllAsRead(): void
{
static::unread()->update(['read_at' => now()]);
}
}

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',
]; ];
/** /**
@ -74,7 +75,7 @@ public function getFullNameAttribute(): string
} }
/** /**
* @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

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\PlatformEnum; use App\Enums\PlatformEnum;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -12,7 +13,9 @@
*/ */
class PlatformChannelPost extends Model class PlatformChannelPost extends Model
{ {
/** @use HasFactory<Factory<PlatformChannelPost>> */
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'platform', 'platform',
'channel_id', 'channel_id',

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;
/** /**
@ -14,6 +14,7 @@
* @property int $platform_channel_id * @property int $platform_channel_id
* @property bool $is_active * @property bool $is_active
* @property int $priority * @property int $priority
* @property bool|null $auto_approve
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at
*/ */
@ -26,17 +27,20 @@ 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',
'auto_approve',
]; ];
protected $casts = [ protected $casts = [
'is_active' => 'boolean' 'is_active' => 'boolean',
'auto_approve' => 'boolean',
]; ];
/** /**
@ -63,4 +67,13 @@ public function keywords(): HasMany
return $this->hasMany(Keyword::class, 'feed_id', 'feed_id') return $this->hasMany(Keyword::class, 'feed_id', 'feed_id')
->where('platform_channel_id', $this->platform_channel_id); ->where('platform_channel_id', $this->platform_channel_id);
} }
/**
* @return HasMany<RouteArticle, $this>
*/
public function routeArticles(): HasMany
{
return $this->hasMany(RouteArticle::class, 'feed_id', 'feed_id')
->where('platform_channel_id', $this->platform_channel_id);
}
} }

104
app/Models/RouteArticle.php Normal file
View file

@ -0,0 +1,104 @@
<?php
namespace App\Models;
use App\Enums\ApprovalStatusEnum;
use App\Enums\PublishStatusEnum;
use App\Events\RouteArticleApproved;
use Database\Factories\RouteArticleFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $feed_id
* @property int $platform_channel_id
* @property int $article_id
* @property ApprovalStatusEnum $approval_status
* @property PublishStatusEnum $publish_status
* @property Carbon|null $validated_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class RouteArticle extends Model
{
/** @use HasFactory<RouteArticleFactory> */
use HasFactory;
protected $fillable = [
'feed_id',
'platform_channel_id',
'article_id',
'approval_status',
'publish_status',
'validated_at',
];
protected $casts = [
'approval_status' => ApprovalStatusEnum::class,
'publish_status' => PublishStatusEnum::class,
'validated_at' => 'datetime',
];
/**
* @return BelongsTo<Route, $this>
*/
public function route(): BelongsTo
{
return $this->belongsTo(Route::class, 'feed_id', 'feed_id')
->where('platform_channel_id', $this->platform_channel_id);
}
/**
* @return BelongsTo<Article, $this>
*/
public function article(): BelongsTo
{
return $this->belongsTo(Article::class);
}
/**
* @return BelongsTo<Feed, $this>
*/
public function feed(): BelongsTo
{
return $this->belongsTo(Feed::class);
}
/**
* @return BelongsTo<PlatformChannel, $this>
*/
public function platformChannel(): BelongsTo
{
return $this->belongsTo(PlatformChannel::class);
}
public function isPending(): bool
{
return $this->approval_status === ApprovalStatusEnum::PENDING;
}
public function isApproved(): bool
{
return $this->approval_status === ApprovalStatusEnum::APPROVED;
}
public function isRejected(): bool
{
return $this->approval_status === ApprovalStatusEnum::REJECTED;
}
public function approve(): void
{
$this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
event(new RouteArticleApproved($this));
}
public function reject(): void
{
$this->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
}
}

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'];
@ -69,4 +71,14 @@ public static function setArticlePublishingInterval(int $minutes): void
{ {
static::set('article_publishing_interval', (string) $minutes); static::set('article_publishing_interval', (string) $minutes);
} }
public static function getFeedStalenessThreshold(): int
{
return (int) static::get('feed_staleness_threshold', 48);
}
public static function setFeedStalenessThreshold(int $hours): void
{
static::set('feed_staleness_threshold', (string) $hours);
}
} }

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@ -10,8 +11,8 @@
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<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,6 +47,7 @@ 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;
} }
@ -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

@ -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
@ -93,6 +94,7 @@ public function getCommunityId(string $communityName, string $token): int
} }
$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,
]); ]);
} }
} }
@ -198,17 +201,20 @@ public function getLanguages(): array
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)
@ -23,6 +24,7 @@ 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;
@ -65,5 +68,4 @@ private function createPost(string $token, array $extractedData, PlatformChannel
$languageId $languageId
); );
} }
} }

View file

@ -3,8 +3,14 @@
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\Events\NewArticleFetched;
use App\Events\RouteArticleApproved;
use App\Listeners\LogActionListener;
use App\Listeners\LogExceptionToDatabase; use App\Listeners\LogExceptionToDatabase;
use App\Listeners\PublishApprovedArticleListener;
use App\Listeners\ValidateArticleListener;
use Error; use Error;
use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
@ -14,25 +20,28 @@
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,
); );
Event::listen( Event::listen(
\App\Events\NewArticleFetched::class, NewArticleFetched::class,
\App\Listeners\ValidateArticleListener::class, ValidateArticleListener::class,
); );
Event::listen( Event::listen(
\App\Events\ArticleApproved::class, RouteArticleApproved::class,
\App\Listeners\PublishApprovedArticleListener::class, PublishApprovedArticleListener::class,
); );
app()->make(ExceptionHandler::class) app()->make(ExceptionHandler::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();
@ -54,7 +54,7 @@ private function getArticlesFromRssFeed(Feed $feed): Collection
} }
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 [];
@ -155,8 +155,8 @@ 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

@ -2,7 +2,13 @@
namespace App\Services\Article; namespace App\Services\Article;
use App\Enums\ApprovalStatusEnum;
use App\Models\Article; use App\Models\Article;
use App\Models\Keyword;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Models\Setting;
use Illuminate\Support\Collection;
class ValidationService class ValidationService
{ {
@ -12,11 +18,10 @@ public function __construct(
public function validate(Article $article): Article public function validate(Article $article): Article
{ {
logger('Checking keywords for article: ' . $article->id); logger('Validating article for routes: '.$article->id);
$articleData = $this->articleFetcher->fetchArticleData($article); $articleData = $this->articleFetcher->fetchArticleData($article);
// Update article with fetched metadata (title, description)
$updateData = []; $updateData = [];
if (! empty($articleData)) { if (! empty($articleData)) {
@ -28,54 +33,84 @@ public function validate(Article $article): Article
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';
$article->update($updateData);
return $article->refresh();
}
// Validate content against keywords. If validation fails, reject.
// If validation passes, leave approval_status as-is (pending) —
// the listener decides whether to auto-approve based on settings.
$validationResult = $this->validateByKeywords($articleData['full_article']);
if (! $validationResult) {
$updateData['approval_status'] = 'rejected';
}
$updateData['validated_at'] = now(); $updateData['validated_at'] = now();
$article->update($updateData); $article->update($updateData);
return $article->refresh(); return $article->refresh();
} }
private function validateByKeywords(string $full_article): bool $updateData['validated_at'] = now();
$article->update($updateData);
$this->createRouteArticles($article, $articleData['full_article']);
return $article->refresh();
}
private function createRouteArticles(Article $article, string $content): void
{ {
// Belgian news content keywords - broader set for Belgian news relevance $activeRoutes = Route::where('feed_id', $article->feed_id)
$keywords = [ ->where('is_active', true)
// Political parties and leaders ->get();
'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo',
'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA',
// Belgian locations and institutions // Batch-load all active keywords for this feed, grouped by channel
'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels', $keywordsByChannel = Keyword::where('feed_id', $article->feed_id)
'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi', ->where('is_active', true)
'parliament', 'government', 'minister', 'policy', 'law', 'legislation', ->get()
->groupBy('platform_channel_id');
// Common Belgian news topics // Match keywords against full article content, title, and description
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', $searchableContent = $content.' '.$article->title.' '.$article->description;
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police'
]; foreach ($activeRoutes as $route) {
$routeKeywords = $keywordsByChannel->get($route->platform_channel_id, collect());
$status = $this->evaluateKeywords($routeKeywords, $searchableContent);
if ($status === ApprovalStatusEnum::PENDING && $this->shouldAutoApprove($route)) {
$status = ApprovalStatusEnum::APPROVED;
}
RouteArticle::firstOrCreate(
[
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'article_id' => $article->id,
],
[
'approval_status' => $status,
'validated_at' => now(),
]
);
}
}
/**
* @param Collection<int, Keyword> $keywords
*/
private function evaluateKeywords(Collection $keywords, string $content): ApprovalStatusEnum
{
if ($keywords->isEmpty()) {
return ApprovalStatusEnum::PENDING;
}
foreach ($keywords as $keyword) { foreach ($keywords as $keyword) {
if (stripos($full_article, $keyword) !== false) { if (stripos($content, $keyword->keyword) !== false) {
return true; return ApprovalStatusEnum::PENDING;
} }
} }
return false; return ApprovalStatusEnum::REJECTED;
}
private function shouldAutoApprove(Route $route): bool
{
if ($route->auto_approve !== null) {
return $route->auto_approve;
}
return ! Setting::isPublishingApprovalsEnabled();
} }
} }

View file

@ -52,8 +52,12 @@ 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 {
@ -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;
@ -47,12 +47,13 @@ public static function getParserForFeed(Feed $feed, string $parserType = 'articl
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;
} }
/** /**
@ -61,7 +62,8 @@ 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);
} }

View file

@ -18,12 +18,13 @@ public static function getParserForFeed(Feed $feed): ?HomepageParserInterface
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,9 @@
namespace App\Services\Http; namespace App\Services\Http;
use Illuminate\Support\Facades\Http;
use Exception; use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class HttpFetcher class HttpFetcher
{ {
@ -23,7 +24,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;
@ -48,20 +49,20 @@ public static function fetchMultipleUrls(array $urls): array
->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 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 +70,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

@ -43,7 +43,7 @@ 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

@ -0,0 +1,69 @@
<?php
namespace App\Services\Notification;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Models\Notification;
use Illuminate\Database\Eloquent\Model;
class NotificationService
{
/**
* @param array<string, mixed> $data
*/
public function send(
NotificationTypeEnum $type,
NotificationSeverityEnum $severity,
string $title,
string $message,
?Model $notifiable = null,
array $data = [],
): Notification {
return Notification::create([
'type' => $type,
'severity' => $severity,
'title' => $title,
'message' => $message,
'data' => $data ?: null,
'notifiable_type' => $notifiable?->getMorphClass(),
'notifiable_id' => $notifiable?->getKey(),
]);
}
/**
* @param array<string, mixed> $data
*/
public function info(
string $title,
string $message,
?Model $notifiable = null,
array $data = [],
): Notification {
return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::INFO, $title, $message, $notifiable, $data);
}
/**
* @param array<string, mixed> $data
*/
public function warning(
string $title,
string $message,
?Model $notifiable = null,
array $data = [],
): Notification {
return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::WARNING, $title, $message, $notifiable, $data);
}
/**
* @param array<string, mixed> $data
*/
public function error(
string $title,
string $message,
?Model $notifiable = null,
array $data = [],
): Notification {
return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::ERROR, $title, $message, $notifiable, $data);
}
}

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

@ -8,19 +8,16 @@
use App\Models\ArticlePublication; use App\Models\ArticlePublication;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\PlatformChannelPost; use App\Models\PlatformChannelPost;
use App\Models\Route; use App\Models\RouteArticle;
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 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,84 +25,39 @@ protected function makePublisher(mixed $account): LemmyPublisher
{ {
return new LemmyPublisher($account); return new LemmyPublisher($account);
} }
/** /**
* Publish an article to the channel specified by a route_article record.
*
* @param array<string, mixed> $extractedData * @param array<string, mixed> $extractedData
* @return Collection<int, ArticlePublication> *
* @throws PublishException * @throws PublishException
*/ */
public function publishToRoutedChannels(Article $article, array $extractedData): Collection public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): ?ArticlePublication
{ {
if (! $article->isValid()) { $article = $routeArticle->article;
throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('CANNOT_PUBLISH_INVALID_ARTICLE')); $channel = $routeArticle->platformChannel;
if (! $channel) {
throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('ROUTE_ARTICLE_MISSING_CHANNEL'));
} }
$feed = $article->feed; if (! $channel->relationLoaded('platformInstance')) {
$channel->load(['platformInstance', 'activePlatformAccounts']);
}
// Get active routes with keywords instead of just channels
$activeRoutes = Route::where('feed_id', $feed->id)
->where('is_active', true)
->with(['platformChannel.platformInstance', 'platformChannel.activePlatformAccounts', 'keywords'])
->orderBy('priority', 'desc')
->get();
// Filter routes based on keyword matches
$matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData) {
return $this->routeMatchesArticle($route, $extractedData);
});
return $matchingRoutes->map(function (Route $route) use ($article, $extractedData) {
$channel = $route->platformChannel;
$account = $channel->activePlatformAccounts()->first(); $account = $channel->activePlatformAccounts()->first();
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_article_id' => $routeArticle->id,
]); ]);
return null; return null;
} }
return $this->publishToChannel($article, $extractedData, $channel, $account); return $this->publishToChannel($article, $extractedData, $channel, $account);
})
->filter();
}
/**
* Check if a route matches an article based on keywords
* @param array<string, mixed> $extractedData
*/
private function routeMatchesArticle(Route $route, array $extractedData): bool
{
// Get active keywords for this route
$activeKeywords = $route->keywords->where('is_active', true);
// If no keywords are defined for this route, the route matches any article
if ($activeKeywords->isEmpty()) {
return true;
}
// Get article content for keyword matching
$articleContent = '';
if (isset($extractedData['full_article'])) {
$articleContent = $extractedData['full_article'];
}
if (isset($extractedData['title'])) {
$articleContent .= ' ' . $extractedData['title'];
}
if (isset($extractedData['description'])) {
$articleContent .= ' ' . $extractedData['description'];
}
// Check if any of the route's keywords match the article content
foreach ($activeKeywords as $keywordModel) {
$keyword = $keywordModel->keyword;
if (stripos($articleContent, $keyword) !== false) {
return true;
}
}
return false;
} }
/** /**
@ -144,15 +96,15 @@ private function publishToChannel(Article $article, array $extractedData, Platfo
'publication_data' => $postData, 'publication_data' => $postData,
]); ]);
$this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [ $this->logSaver->info('Published to channel', $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

@ -11,6 +11,7 @@ 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

View file

@ -1,5 +1,7 @@
<?php <?php
use App\Enums\LogLevelEnum;
use App\Events\ExceptionOccurred;
use App\Http\Middleware\EnsureOnboardingComplete; use App\Http\Middleware\EnsureOnboardingComplete;
use App\Http\Middleware\RedirectIfOnboardingComplete; use App\Http\Middleware\RedirectIfOnboardingComplete;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
@ -22,13 +24,13 @@
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
$exceptions->reportable(function (Throwable $e) { $exceptions->reportable(function (Throwable $e) {
$level = match (true) { $level = match (true) {
$e instanceof Error => \App\Enums\LogLevelEnum::CRITICAL, $e instanceof Error => LogLevelEnum::CRITICAL,
$e instanceof RuntimeException => \App\Enums\LogLevelEnum::ERROR, $e instanceof RuntimeException => LogLevelEnum::ERROR,
$e instanceof InvalidArgumentException => \App\Enums\LogLevelEnum::WARNING, $e instanceof InvalidArgumentException => LogLevelEnum::WARNING,
default => \App\Enums\LogLevelEnum::ERROR, default => LogLevelEnum::ERROR,
}; };
App\Events\ExceptionOccurred::dispatch( ExceptionOccurred::dispatch(
$e, $e,
$level, $level,
$e->getMessage(), $e->getMessage(),

View file

@ -1,6 +1,9 @@
<?php <?php
use App\Providers\AppServiceProvider;
use App\Providers\HorizonServiceProvider;
return [ return [
App\Providers\AppServiceProvider::class, AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class, HorizonServiceProvider::class,
]; ];

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

@ -1,5 +1,7 @@
<?php <?php
use App\Models\User;
return [ return [
/* /*
@ -62,7 +64,7 @@
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class), 'model' => env('AUTH_MODEL', User::class),
], ],
// 'users' => [ // 'users' => [

View file

@ -1,5 +1,13 @@
<?php <?php
use App\Services\Parsers\BelgaArticlePageParser;
use App\Services\Parsers\BelgaArticleParser;
use App\Services\Parsers\GuardianArticlePageParser;
use App\Services\Parsers\GuardianArticleParser;
use App\Services\Parsers\VrtArticlePageParser;
use App\Services\Parsers\VrtArticleParser;
use App\Services\Parsers\VrtHomepageParserAdapter;
return [ return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -24,24 +32,23 @@
'nl' => ['url' => 'https://www.vrt.be/vrtnws/nl/'], 'nl' => ['url' => 'https://www.vrt.be/vrtnws/nl/'],
], ],
'parsers' => [ 'parsers' => [
'homepage' => \App\Services\Parsers\VrtHomepageParserAdapter::class, 'homepage' => VrtHomepageParserAdapter::class,
'article' => \App\Services\Parsers\VrtArticleParser::class, 'article' => VrtArticleParser::class,
'article_page' => \App\Services\Parsers\VrtArticlePageParser::class, 'article_page' => VrtArticlePageParser::class,
], ],
], ],
'belga' => [ 'belga' => [
'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' => BelgaArticleParser::class,
'article' => \App\Services\Parsers\BelgaArticleParser::class, 'article_page' => BelgaArticlePageParser::class,
'article_page' => \App\Services\Parsers\BelgaArticlePageParser::class,
], ],
], ],
'guardian' => [ 'guardian' => [
@ -54,8 +61,8 @@
'en' => ['url' => 'https://www.theguardian.com/international/rss'], 'en' => ['url' => 'https://www.theguardian.com/international/rss'],
], ],
'parsers' => [ 'parsers' => [
'article' => \App\Services\Parsers\GuardianArticleParser::class, 'article' => GuardianArticleParser::class,
'article_page' => \App\Services\Parsers\GuardianArticlePageParser::class, 'article_page' => GuardianArticlePageParser::class,
], ],
], ],
], ],

View file

@ -1,5 +1,8 @@
<?php <?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
return [ return [
@ -76,9 +79,9 @@
*/ */
'middleware' => [ 'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, 'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, 'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, 'validate_csrf_token' => ValidateCsrfToken::class,
], ],
]; ];

View file

@ -2,10 +2,12 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Article;
use App\Models\Feed;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Article> * @extends Factory<Article>
*/ */
class ArticleFactory extends Factory class ArticleFactory extends Factory
{ {
@ -17,16 +19,14 @@ class ArticleFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
'feed_id' => \App\Models\Feed::factory(), 'feed_id' => Feed::factory(),
'url' => $this->faker->url(), 'url' => $this->faker->unique()->url(),
'title' => $this->faker->sentence(), 'title' => $this->faker->sentence(),
'description' => $this->faker->paragraph(), 'description' => $this->faker->paragraph(),
'content' => $this->faker->paragraphs(3, true), 'content' => $this->faker->paragraphs(3, true),
'image_url' => $this->faker->optional()->imageUrl(), 'image_url' => $this->faker->optional()->imageUrl(),
'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'), 'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'),
'author' => $this->faker->optional()->name(), 'author' => $this->faker->optional()->name(),
'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']),
'publish_status' => 'unpublished',
]; ];
} }
} }

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;

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