diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 8b4c9361..d56d3409 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -42,72 +42,3 @@ jobs: - 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="" - - 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"], "")) { - 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 diff --git a/Dockerfile b/Dockerfile index 9fd071d8..9ce1a488 100644 --- a/Dockerfile +++ b/Dockerfile @@ -101,6 +101,9 @@ php artisan db:seed --force || echo "Seeders failed or already run" # Start Horizon in the background php artisan horizon & +# Start the scheduler in the background +php artisan schedule:work & + # Start FrankenPHP exec frankenphp run --config /etc/caddy/Caddyfile EOF diff --git a/Dockerfile.dev b/Dockerfile.dev index 124ace16..41c44083 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -114,6 +114,9 @@ npm run dev & # Start Horizon (queue worker) in background php artisan horizon & +# Scheduler left off in dev on purpose; run schedule:work by hand when needed. +# php artisan schedule:work & + # Start FrankenPHP exec frankenphp run --config /etc/caddy/Caddyfile EOF diff --git a/app/Actions/CreateChannelAction.php b/app/Actions/CreateChannelAction.php index 5fdd3183..bd7e4378 100644 --- a/app/Actions/CreateChannelAction.php +++ b/app/Actions/CreateChannelAction.php @@ -10,7 +10,7 @@ class CreateChannelAction { - public function execute(string $name, int $platformInstanceId, ?int $languageId = null, ?string $description = null): PlatformChannel + public function execute(string $name, int $communityId, int $platformInstanceId, ?int $languageId = null, ?string $description = null): PlatformChannel { $platformInstance = PlatformInstance::findOrFail($platformInstanceId); @@ -22,10 +22,10 @@ public function execute(string $name, int $platformInstanceId, ?int $languageId throw new RuntimeException('No active platform accounts found for this instance. Please create a platform account first.'); } - return DB::transaction(function () use ($name, $platformInstanceId, $languageId, $description, $activeAccounts) { + return DB::transaction(function () use ($name, $communityId, $platformInstanceId, $languageId, $description, $activeAccounts) { $channel = PlatformChannel::create([ 'platform_instance_id' => $platformInstanceId, - 'channel_id' => $name, + 'channel_id' => $communityId, 'name' => $name, 'display_name' => ucfirst($name), 'description' => $description, diff --git a/app/Actions/PublishRouteArticleAction.php b/app/Actions/PublishRouteArticleAction.php new file mode 100644 index 00000000..b6a2f204 --- /dev/null +++ b/app/Actions/PublishRouteArticleAction.php @@ -0,0 +1,107 @@ +article; + + $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]); + + try { + $extractedData = $this->articleFetcher->fetchArticleData($article); + $outcome = $this->publishingService->publishRouteArticle($routeArticle, $extractedData); + } catch (Exception $e) { + $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); + + ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [ + 'article_id' => $article->id, + 'error' => $e->getMessage(), + ]); + + $this->notificationService->send( + NotificationTypeEnum::PUBLISH_FAILED, + NotificationSeverityEnum::ERROR, + "Publish failed: {$article->title}", + $e->getMessage(), + $article, + ); + + throw $e; + } + + match (true) { + $outcome->succeeded() => $this->recordPublished($routeArticle), + $outcome->wasSkipped() => $this->recordSkipped($routeArticle, $outcome), + default => $this->recordFailed($routeArticle, $outcome), + }; + + return $outcome; + } + + private function recordPublished(RouteArticle $routeArticle): void + { + $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]); + + ActionPerformed::dispatch('Published article', LogLevelEnum::INFO, [ + 'article_id' => $routeArticle->article->id, + 'title' => $routeArticle->article->title, + ]); + } + + private function recordSkipped(RouteArticle $routeArticle, PublishOutcome $outcome): void + { + $routeArticle->update(['publish_status' => PublishStatusEnum::SKIPPED]); + + ActionPerformed::dispatch('Skipped publishing article', LogLevelEnum::INFO, [ + 'article_id' => $routeArticle->article->id, + 'title' => $routeArticle->article->title, + 'reason' => $outcome->reason, + ]); + } + + private function recordFailed(RouteArticle $routeArticle, PublishOutcome $outcome): void + { + $article = $routeArticle->article; + + $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); + + ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [ + 'article_id' => $article->id, + 'title' => $article->title, + 'reason' => $outcome->reason, + ]); + + $this->notificationService->send( + NotificationTypeEnum::PUBLISH_FAILED, + NotificationSeverityEnum::WARNING, + "Publish failed: {$article->title}", + $outcome->reason ?? 'No publication was created for this article.', + $article, + ); + } +} diff --git a/app/Enums/PublishStatusEnum.php b/app/Enums/PublishStatusEnum.php index 03260e30..c4e49b2a 100644 --- a/app/Enums/PublishStatusEnum.php +++ b/app/Enums/PublishStatusEnum.php @@ -7,5 +7,6 @@ enum PublishStatusEnum: string case UNPUBLISHED = 'unpublished'; case PUBLISHING = 'publishing'; case PUBLISHED = 'published'; + case SKIPPED = 'skipped'; case ERROR = 'error'; } diff --git a/app/Http/Controllers/Api/V1/PlatformChannelsController.php b/app/Http/Controllers/Api/V1/PlatformChannelsController.php index 443ba540..22c349ea 100644 --- a/app/Http/Controllers/Api/V1/PlatformChannelsController.php +++ b/app/Http/Controllers/Api/V1/PlatformChannelsController.php @@ -7,6 +7,8 @@ use App\Http\Resources\PlatformChannelResource; use App\Models\PlatformAccount; use App\Models\PlatformChannel; +use App\Models\PlatformInstance; +use App\Services\Platform\CommunityDirectory; use Exception; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Http\JsonResponse; @@ -40,8 +42,12 @@ public function store(StorePlatformChannelRequest $request, CreateChannelAction try { $validated = $request->validated(); + $instance = PlatformInstance::query()->findOrFail((int) $validated['platform_instance_id']); + $name = app(CommunityDirectory::class)->name($instance, (int) $validated['channel_id']); + $channel = $createChannelAction->execute( - $validated['name'], + $name, + (int) $validated['channel_id'], $validated['platform_instance_id'], $validated['language_id'] ?? null, $validated['description'] ?? null, diff --git a/app/Http/Requests/StorePlatformChannelRequest.php b/app/Http/Requests/StorePlatformChannelRequest.php index 0a1af8c6..03453bb8 100644 --- a/app/Http/Requests/StorePlatformChannelRequest.php +++ b/app/Http/Requests/StorePlatformChannelRequest.php @@ -2,6 +2,9 @@ namespace App\Http\Requests; +use App\Models\PlatformInstance; +use App\Services\Platform\CommunityDirectory; +use Exception; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -17,19 +20,22 @@ public function authorize(): bool */ public function rules(): array { + try { + $communityRules = [ + Rule::in($this->communityIds()), + Rule::unique('platform_channels', 'channel_id') + ->where('platform_instance_id', $this->input('platform_instance_id')), + ]; + } catch (Exception $e) { + // Falling through to Rule::in([]) would report the community as non-existent + // when the truth is we never reached the instance to check. + $message = 'Could not reach this instance to list its communities: '.$e->getMessage(); + $communityRules = [fn ($attribute, $value, $fail) => $fail($message)]; + } + return [ 'platform_instance_id' => 'required|exists:platform_instances,id', - // name doubles as the Lemmy community slug (CreateChannelAction copies it - // verbatim into channel_id for community lookup at publish time), so it must - // be slug format and unique per instance — matching the Livewire create form. - 'name' => [ - 'required', - 'string', - 'max:255', - 'regex:/^[a-z0-9_]+$/', - Rule::unique('platform_channels', 'name') - ->where('platform_instance_id', $this->input('platform_instance_id')), - ], + 'channel_id' => ['required', 'integer', ...$communityRules], 'language_id' => 'nullable|exists:languages,id', 'description' => 'nullable|string', ]; @@ -41,8 +47,24 @@ public function rules(): array public function messages(): array { return [ - 'name.regex' => 'The name must be a valid community slug (lowercase letters, numbers, and underscores only).', - 'name.unique' => 'A channel with this name already exists for this instance.', + 'channel_id.in' => 'That community does not exist on the selected instance.', + 'channel_id.unique' => 'A channel for this community already exists.', ]; } + + /** + * @return array + */ + private function communityIds(): array + { + $instance = PlatformInstance::query()->find((int) $this->input('platform_instance_id')); + + if (! $instance) { + return []; + } + + return collect(app(CommunityDirectory::class)->forInstance($instance)) + ->pluck('id') + ->all(); + } } diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php index fd9a8118..e4ce99c7 100644 --- a/app/Jobs/PublishNextArticleJob.php +++ b/app/Jobs/PublishNextArticleJob.php @@ -2,19 +2,14 @@ namespace App\Jobs; +use App\Actions\PublishRouteArticleAction; 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\Models\ArticlePublication; use App\Models\RouteArticle; use App\Models\Setting; -use App\Services\Article\ArticleFetcher; -use App\Services\Notification\NotificationService; -use App\Services\Publishing\ArticlePublishingService; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -38,7 +33,7 @@ public function __construct() * * @throws PublishException */ - public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService, NotificationService $notificationService): void + public function handle(PublishRouteArticleAction $publishRouteArticle): void { $interval = Setting::getArticlePublishingInterval(); @@ -72,52 +67,6 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService 'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id, ]); - $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]); - - try { - $extractedData = $articleFetcher->fetchArticleData($article); - $publication = $publishingService->publishRouteArticle($routeArticle, $extractedData); - - if ($publication) { - $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]); - - ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [ - 'article_id' => $article->id, - '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) { - $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); - - ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [ - 'article_id' => $article->id, - 'error' => $e->getMessage(), - ]); - - $notificationService->send( - NotificationTypeEnum::PUBLISH_FAILED, - NotificationSeverityEnum::ERROR, - "Publish failed: {$article->title}", - $e->getMessage(), - $article, - ); - - throw $e; - } + $publishRouteArticle->execute($routeArticle); } } diff --git a/app/Jobs/SyncChannelPostsJob.php b/app/Jobs/SyncChannelPostsJob.php index d598733f..ba1ebaf1 100644 --- a/app/Jobs/SyncChannelPostsJob.php +++ b/app/Jobs/SyncChannelPostsJob.php @@ -68,9 +68,7 @@ private function syncLemmyChannelPosts(LogSaver $logSaver): void $api = $this->makeApiService($this->channel->platformInstance->url); $token = $this->getAuthToken($api, $account); - $communityId = $api->resolveCommunityId($this->channel->channel_id, $token); - - $api->syncChannelPosts($token, $communityId, $this->channel->name); + $api->syncChannelPosts($token, $this->channel, $this->channel->channel_id); $logSaver->info('Channel posts synced successfully', $this->channel); } catch (Exception $e) { diff --git a/app/Listeners/PublishApprovedArticleListener.php b/app/Listeners/PublishApprovedArticleListener.php index b08e3b7e..ae9524e7 100644 --- a/app/Listeners/PublishApprovedArticleListener.php +++ b/app/Listeners/PublishApprovedArticleListener.php @@ -2,15 +2,8 @@ namespace App\Listeners; -use App\Enums\LogLevelEnum; -use App\Enums\NotificationSeverityEnum; -use App\Enums\NotificationTypeEnum; -use App\Enums\PublishStatusEnum; -use App\Events\ActionPerformed; +use App\Actions\PublishRouteArticleAction; use App\Events\RouteArticleApproved; -use App\Services\Article\ArticleFetcher; -use App\Services\Notification\NotificationService; -use App\Services\Publishing\ArticlePublishingService; use Exception; use Illuminate\Contracts\Queue\ShouldQueue; @@ -19,9 +12,7 @@ class PublishApprovedArticleListener implements ShouldQueue public string $queue = 'publishing'; public function __construct( - private ArticleFetcher $articleFetcher, - private ArticlePublishingService $publishingService, - private NotificationService $notificationService, + private PublishRouteArticleAction $publishRouteArticle, ) {} public function handle(RouteArticleApproved $event): void @@ -29,7 +20,6 @@ public function handle(RouteArticleApproved $event): void $routeArticle = $event->routeArticle; $article = $routeArticle->article; - // Skip if already published to this channel if ($article->articlePublications() ->where('platform_channel_id', $routeArticle->platform_channel_id) ->exists() @@ -37,50 +27,10 @@ public function handle(RouteArticleApproved $event): void return; } - $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]); - try { - $extractedData = $this->articleFetcher->fetchArticleData($article); - $publication = $this->publishingService->publishRouteArticle($routeArticle, $extractedData); - - if ($publication) { - $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]); - - ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [ - 'article_id' => $article->id, - 'title' => $article->title, - ]); - } else { - $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); - - ActionPerformed::dispatch('No publication created for approved article', LogLevelEnum::WARNING, [ - 'article_id' => $article->id, - '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) { - $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); - - ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [ - 'article_id' => $article->id, - 'error' => $e->getMessage(), - ]); - - $this->notificationService->send( - NotificationTypeEnum::PUBLISH_FAILED, - NotificationSeverityEnum::ERROR, - "Publish failed: {$article->title}", - $e->getMessage(), - $article, - ); + $this->publishRouteArticle->execute($routeArticle); + } catch (Exception) { + // The action has already recorded the failure and notified. } } } diff --git a/app/Livewire/Channels.php b/app/Livewire/Channels.php index 0b785186..ebb7099d 100644 --- a/app/Livewire/Channels.php +++ b/app/Livewire/Channels.php @@ -7,6 +7,8 @@ use App\Models\PlatformAccount; use App\Models\PlatformChannel; use App\Models\PlatformInstance; +use App\Services\Platform\CommunityDirectory; +use Exception; use Illuminate\Contracts\View\View; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Validation\Rule; @@ -19,10 +21,15 @@ class Channels extends Component public bool $showCreateModal = false; - public string $newName = ''; + public ?int $newCommunityId = null; public ?int $newPlatformInstanceId = null; + /** @var array */ + public array $availableCommunities = []; + + public ?string $communityLoadError = null; + public ?int $newLanguageId = null; public string $newDescription = ''; @@ -36,11 +43,44 @@ public function toggle(int $channelId): void public function openCreateModal(): void { - $this->reset(['newName', 'newPlatformInstanceId', 'newLanguageId', 'newDescription']); + $this->reset(['newCommunityId', 'newPlatformInstanceId', 'newLanguageId', 'newDescription', 'availableCommunities', 'communityLoadError']); $this->resetErrorBag(); $this->showCreateModal = true; } + public function updatedNewPlatformInstanceId(?int $value): void + { + $this->reset(['newCommunityId', 'availableCommunities', 'communityLoadError']); + + if (! $value) { + return; + } + + $instance = PlatformInstance::find($value); + + if (! $instance) { + return; + } + + try { + $this->availableCommunities = app(CommunityDirectory::class)->forInstance($instance); + } catch (Exception $e) { + $this->communityLoadError = 'Could not reach this instance to list its communities: '.$e->getMessage(); + } + } + + public function refreshCommunities(): void + { + $instance = $this->newPlatformInstanceId ? PlatformInstance::find($this->newPlatformInstanceId) : null; + + if (! $instance) { + return; + } + + app(CommunityDirectory::class)->forget($instance); + $this->updatedNewPlatformInstanceId($this->newPlatformInstanceId); + } + public function closeCreateModal(): void { $this->showCreateModal = false; @@ -49,36 +89,33 @@ public function closeCreateModal(): void public function createChannel(CreateChannelAction $action): void { $this->validate([ - // name doubles as the Lemmy community slug (used verbatim as channel_id for - // community lookup at publish time), so it must be lowercase slug format. - 'newName' => [ + 'newCommunityId' => [ 'required', - 'string', - 'max:255', - 'regex:/^[a-z0-9_]+$/', - Rule::unique('platform_channels', 'name') + 'integer', + Rule::in(collect($this->availableCommunities)->pluck('id')->all()), + Rule::unique('platform_channels', 'channel_id') ->where('platform_instance_id', $this->newPlatformInstanceId), ], 'newPlatformInstanceId' => 'required|integer|exists:platform_instances,id', 'newLanguageId' => 'nullable|integer|exists:languages,id', ], [ - 'newName.regex' => 'The name must be a valid community slug (lowercase letters, numbers, and underscores only).', - 'newName.unique' => 'A channel with this name already exists for this instance.', + 'newCommunityId.in' => 'Select a community from this instance.', + 'newCommunityId.unique' => 'A channel for this community already exists.', ]); + $name = collect($this->availableCommunities)->firstWhere('id', $this->newCommunityId)['name'] ?? null; + try { $action->execute( - $this->newName, + $name, + $this->newCommunityId, $this->newPlatformInstanceId, $this->newLanguageId, // Blade textarea binds an empty string when blank; the action expects null for "no description". $this->newDescription !== '' ? $this->newDescription : null, ); } catch (UniqueConstraintViolationException $e) { - // Unreachable via this form (the unique rule above catches duplicates first), - // but the (platform_instance_id, channel_id) index can still fire if channel_id - // ever drifts from name. Surface it as a field error instead of a 500. - $this->addError('newName', 'A channel with this name already exists for this instance.'); + $this->addError('newCommunityId', 'A channel for this community already exists.'); return; } catch (RuntimeException $e) { diff --git a/app/Livewire/Onboarding.php b/app/Livewire/Onboarding.php index 919514ea..2e1a88a2 100644 --- a/app/Livewire/Onboarding.php +++ b/app/Livewire/Onboarding.php @@ -17,8 +17,10 @@ use App\Models\Route; use App\Models\Setting; use App\Services\OnboardingService; +use App\Services\Platform\CommunityDirectory; use Exception; use Illuminate\Contracts\View\View; +use Illuminate\Validation\Rule; use InvalidArgumentException; use Livewire\Attributes\Locked; use Livewire\Component; @@ -49,7 +51,12 @@ class Onboarding extends Component public string $feedDescription = ''; // Channel form - public string $channelName = ''; + public ?int $channelCommunityId = null; + + /** @var array */ + public array $availableCommunities = []; + + public ?string $communityLoadError = null; public ?int $platformInstanceId = null; @@ -117,10 +124,11 @@ public function mount(): void // Pre-fill channel form if exists $channel = PlatformChannel::where('is_active', true)->first(); if ($channel) { - $this->channelName = $channel->name; $this->platformInstanceId = $channel->platform_instance_id; $this->channelLanguageId = $channel->language_id; $this->channelDescription = $channel->description ?? ''; + $this->loadCommunities(); + $this->channelCommunityId = $channel->channel_id; } // Pre-fill route form if exists @@ -252,16 +260,61 @@ public function createFeed(): void } } + public function updatedPlatformInstanceId(?int $value): void + { + $this->reset(['channelCommunityId', 'availableCommunities', 'communityLoadError']); + + if ($value) { + $this->loadCommunities(); + } + } + + public function refreshCommunities(): void + { + $instance = $this->platformInstanceId ? PlatformInstance::find($this->platformInstanceId) : null; + + if (! $instance) { + return; + } + + app(CommunityDirectory::class)->forget($instance); + $this->loadCommunities(); + } + + private function loadCommunities(): void + { + $this->availableCommunities = []; + $this->communityLoadError = null; + + $instance = $this->platformInstanceId ? PlatformInstance::find($this->platformInstanceId) : null; + + if (! $instance) { + return; + } + + try { + $this->availableCommunities = app(CommunityDirectory::class)->forInstance($instance); + } catch (Exception $e) { + $this->communityLoadError = 'Could not reach this instance to list its communities: '.$e->getMessage(); + } + } + public function createChannel(): void { $this->formErrors = []; $this->isLoading = true; $this->validate([ - 'channelName' => 'required|string|max:255', + 'channelCommunityId' => [ + 'required', + 'integer', + Rule::in(collect($this->availableCommunities)->pluck('id')->all()), + ], 'platformInstanceId' => 'required|exists:platform_instances,id', 'channelLanguageId' => 'required|exists:languages,id', 'channelDescription' => 'nullable|string|max:1000', + ], [ + 'channelCommunityId.in' => 'Select a community from this instance.', ]); // If language changed, reset feed form @@ -274,11 +327,14 @@ public function createChannel(): void } $this->previousChannelLanguageId = $this->channelLanguageId; + $name = collect($this->availableCommunities)->firstWhere('id', $this->channelCommunityId)['name'] ?? null; + try { $channel = $this->createChannelAction->execute( - $this->channelName, - $this->platformInstanceId, - $this->channelLanguageId, + $name, + (int) $this->channelCommunityId, + (int) $this->platformInstanceId, + $this->channelLanguageId !== null ? (int) $this->channelLanguageId : null, $this->channelDescription ?: null, ); diff --git a/app/Models/PlatformChannel.php b/app/Models/PlatformChannel.php index 055b5f60..12f88fac 100644 --- a/app/Models/PlatformChannel.php +++ b/app/Models/PlatformChannel.php @@ -15,7 +15,7 @@ * @property int $id * @property int $platform_instance_id * @property PlatformInstance $platformInstance - * @property string $channel_id + * @property int $channel_id * @property string $name * @property int $language_id * @property Language|null $language @@ -40,6 +40,7 @@ class PlatformChannel extends Model protected $casts = [ 'is_active' => 'boolean', + 'channel_id' => 'integer', ]; /** diff --git a/app/Models/PlatformChannelPost.php b/app/Models/PlatformChannelPost.php index a411b241..9b94da42 100644 --- a/app/Models/PlatformChannelPost.php +++ b/app/Models/PlatformChannelPost.php @@ -2,13 +2,12 @@ namespace App\Models; -use App\Enums\PlatformEnum; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** - * @method static where(string $string, PlatformEnum $platform) * @method static updateOrCreate(array $array, array $array1) */ class PlatformChannelPost extends Model @@ -17,9 +16,7 @@ class PlatformChannelPost extends Model use HasFactory; protected $fillable = [ - 'platform', - 'channel_id', - 'channel_name', + 'platform_channel_id', 'post_id', 'url', 'title', @@ -33,26 +30,24 @@ protected function casts(): array { return [ 'posted_at' => 'datetime', - 'platform' => PlatformEnum::class, ]; } - public static function urlExists(PlatformEnum $platform, string $channelId, string $url): bool + /** + * @return BelongsTo + */ + public function platformChannel(): BelongsTo { - return self::where('platform', $platform) - ->where('channel_id', $channelId) - ->where('url', $url) - ->exists(); + return $this->belongsTo(PlatformChannel::class); } - public static function duplicateExists(PlatformEnum $platform, string $channelId, ?string $url, ?string $title): bool + public static function duplicateExists(PlatformChannel $channel, ?string $url, ?string $title): bool { if (! $url && ! $title) { return false; } - return self::where('platform', $platform) - ->where('channel_id', $channelId) + return self::where('platform_channel_id', $channel->id) ->where(function ($query) use ($url, $title) { if ($url) { $query->orWhere('url', $url); @@ -64,16 +59,14 @@ public static function duplicateExists(PlatformEnum $platform, string $channelId ->exists(); } - public static function storePost(PlatformEnum $platform, string $channelId, ?string $channelName, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self + public static function storePost(PlatformChannel $channel, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self { return self::updateOrCreate( [ - 'platform' => $platform, - 'channel_id' => $channelId, + 'platform_channel_id' => $channel->id, 'post_id' => $postId, ], [ - 'channel_name' => $channelName, 'url' => $url, 'title' => $title, 'posted_at' => $postedAt ?? now(), diff --git a/app/Models/RouteArticle.php b/app/Models/RouteArticle.php index 7e71b524..8b0bc42e 100644 --- a/app/Models/RouteArticle.php +++ b/app/Models/RouteArticle.php @@ -92,6 +92,10 @@ public function isRejected(): bool public function approve(): void { + if ($this->isApproved()) { + return; + } + $this->update(['approval_status' => ApprovalStatusEnum::APPROVED]); event(new RouteArticleApproved($this)); diff --git a/app/Modules/Lemmy/Services/LemmyApiService.php b/app/Modules/Lemmy/Services/LemmyApiService.php index 0ab58d21..0a4dba47 100644 --- a/app/Modules/Lemmy/Services/LemmyApiService.php +++ b/app/Modules/Lemmy/Services/LemmyApiService.php @@ -2,7 +2,7 @@ namespace App\Modules\Lemmy\Services; -use App\Enums\PlatformEnum; +use App\Models\PlatformChannel; use App\Models\PlatformChannelPost; use App\Modules\Lemmy\LemmyRequest; use Exception; @@ -84,18 +84,35 @@ public function login(string $username, string $password): ?string } /** - * Resolve a PlatformChannel.channel_id to a numeric Lemmy community id. - * - * channel_id holds either a community slug (the usual case — CreateChannelAction - * copies `name` into it) or an already-numeric community id. Callers that need the - * numeric id should use this rather than reimplementing the check, so the two forms - * stay handled identically everywhere. + * @return array */ - public function resolveCommunityId(string $channelId, string $token): int + public function listCommunities(?string $token = null): array { - return is_numeric($channelId) - ? (int) $channelId - : $this->getCommunityId($channelId, $token); + $request = new LemmyRequest($this->instance, $token); + $response = $request->get('community/list', [ + 'type_' => 'Local', + 'limit' => 50, + 'sort' => 'TopAll', + ]); + + if (! $response->successful()) { + throw new Exception('Failed to list communities: '.$response->status()); + } + + /** @var array> $communities */ + $communities = $response->json('communities') ?? []; + + return collect($communities) + ->pluck('community') + ->reject(fn ($community) => ($community['removed'] ?? false) || ($community['deleted'] ?? false)) + ->map(fn ($community) => [ + 'id' => (int) $community['id'], + 'name' => (string) $community['name'], + 'title' => (string) ($community['title'] ?? $community['name']), + ]) + ->sortBy('name') + ->values() + ->all(); } public function getCommunityId(string $communityName, string $token): int @@ -117,12 +134,12 @@ public function getCommunityId(string $communityName, string $token): int } } - public function syncChannelPosts(string $token, int $platformChannelId, string $communityName): void + public function syncChannelPosts(string $token, PlatformChannel $channel, int $communityId): void { try { $request = new LemmyRequest($this->instance, $token); $response = $request->get('post/list', [ - 'community_id' => $platformChannelId, + 'community_id' => $communityId, 'limit' => 50, 'sort' => 'New', ]); @@ -130,7 +147,7 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $ if (! $response->successful()) { logger()->warning('Failed to sync channel posts', [ 'status' => $response->status(), - 'platform_channel_id' => $platformChannelId, + 'platform_channel_id' => $channel->id, ]); return; @@ -143,9 +160,7 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $ $post = $postData['post']; PlatformChannelPost::storePost( - PlatformEnum::LEMMY, - (string) $platformChannelId, - $communityName, + $channel, (string) $post['id'], $post['url'] ?? null, $post['name'] ?? null, @@ -154,14 +169,14 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $ } logger()->info('Synced channel posts', [ - 'platform_channel_id' => $platformChannelId, + 'platform_channel_id' => $channel->id, 'posts_count' => count($posts), ]); } catch (Exception $e) { logger()->error('Exception while syncing channel posts', [ 'error' => $e->getMessage(), - 'platform_channel_id' => $platformChannelId, + 'platform_channel_id' => $channel->id, ]); } } diff --git a/app/Modules/Lemmy/Services/LemmyPublisher.php b/app/Modules/Lemmy/Services/LemmyPublisher.php index be7855a0..11d7e300 100644 --- a/app/Modules/Lemmy/Services/LemmyPublisher.php +++ b/app/Modules/Lemmy/Services/LemmyPublisher.php @@ -54,13 +54,11 @@ private function createPost(string $token, array $extractedData, PlatformChannel { $languageId = $extractedData['language_id'] ?? null; - $communityId = $this->api->resolveCommunityId($channel->channel_id, $token); - return $this->api->createPost( $token, $extractedData['title'] ?? 'Untitled', $extractedData['description'] ?? '', - $communityId, + $channel->channel_id, $article->url, $extractedData['thumbnail'] ?? null, $languageId diff --git a/app/Services/Platform/CommunityDirectory.php b/app/Services/Platform/CommunityDirectory.php new file mode 100644 index 00000000..75359159 --- /dev/null +++ b/app/Services/Platform/CommunityDirectory.php @@ -0,0 +1,51 @@ + + */ + public function forInstance(PlatformInstance $instance): array + { + return Cache::remember( + self::cacheKey($instance), + self::TTL_SECONDS, + fn () => $this->makeApi($instance->url)->listCommunities() + ); + } + + public function forget(PlatformInstance $instance): void + { + Cache::forget(self::cacheKey($instance)); + } + + public function has(PlatformInstance $instance, int $communityId): bool + { + return collect($this->forInstance($instance)) + ->contains(fn (array $community) => $community['id'] === $communityId); + } + + public function name(PlatformInstance $instance, int $communityId): ?string + { + return collect($this->forInstance($instance)) + ->firstWhere('id', $communityId)['name'] ?? null; + } + + protected function makeApi(string $instanceUrl): LemmyApiService + { + return new LemmyApiService($instanceUrl); + } + + private static function cacheKey(PlatformInstance $instance): string + { + return "platform:communities:{$instance->id}"; + } +} diff --git a/app/Services/Publishing/ArticlePublishingService.php b/app/Services/Publishing/ArticlePublishingService.php index 73079e1d..622e5cb7 100644 --- a/app/Services/Publishing/ArticlePublishingService.php +++ b/app/Services/Publishing/ArticlePublishingService.php @@ -12,10 +12,16 @@ use App\Modules\Lemmy\Services\LemmyPublisher; use App\Services\Log\LogSaver; use Exception; +use Illuminate\Contracts\Cache\LockTimeoutException; +use Illuminate\Support\Facades\Cache; use RuntimeException; class ArticlePublishingService { + private const LOCK_TTL_SECONDS = 180; + + private const LOCK_WAIT_SECONDS = 15; + public function __construct(private LogSaver $logSaver) {} /** @@ -33,7 +39,7 @@ protected function makePublisher(mixed $account): LemmyPublisher * * @throws PublishException */ - public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): ?ArticlePublication + public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): PublishOutcome { $article = $routeArticle->article; $channel = $routeArticle->platformChannel; @@ -54,7 +60,7 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted 'route_article_id' => $routeArticle->id, ]); - return null; + return PublishOutcome::failure('No active account for channel'); } return $this->publishToChannel($article, $extractedData, $channel, $account); @@ -63,24 +69,51 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted /** * @param array $extractedData */ - private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication + private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): PublishOutcome + { + $lock = Cache::lock("publish:{$article->id}:{$channel->id}", self::LOCK_TTL_SECONDS); + + try { + return $lock->block(self::LOCK_WAIT_SECONDS, function () use ($article, $extractedData, $channel, $account) { + $alreadyPublished = ArticlePublication::where('article_id', $article->id) + ->where('platform_channel_id', $channel->id) + ->exists(); + + if ($alreadyPublished) { + $this->logSaver->info('Skipping duplicate: already published to channel', $channel, [ + 'article_id' => $article->id, + ]); + + return PublishOutcome::skipped('Already published to this channel'); + } + + return $this->doPublishToChannel($article, $extractedData, $channel, $account); + }); + } catch (LockTimeoutException $e) { + $this->logSaver->info('Skipping publish: another worker holds the lock', $channel, [ + 'article_id' => $article->id, + ]); + + return PublishOutcome::skipped('Another worker is publishing this article'); + } + } + + /** + * @param array $extractedData + */ + private function doPublishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): PublishOutcome { try { // Check if this URL or title was already posted to this channel $title = $extractedData['title'] ?? $article->title; - if (PlatformChannelPost::duplicateExists( - $channel->platformInstance->platform, - (string) $channel->channel_id, - $article->url, - $title - )) { + if (PlatformChannelPost::duplicateExists($channel, $article->url, $title)) { $this->logSaver->info('Skipping duplicate: URL or title already posted to channel', $channel, [ 'article_id' => $article->id, 'url' => $article->url, 'title' => $title, ]); - return null; + return PublishOutcome::skipped('URL or title already posted to this channel'); } $publisher = $this->makePublisher($account); @@ -100,14 +133,14 @@ private function publishToChannel(Article $article, array $extractedData, Platfo 'article_id' => $article->id, ]); - return $publication; + return PublishOutcome::published($publication); } catch (Exception $e) { $this->logSaver->warning('Failed to publish to channel', $channel, [ 'article_id' => $article->id, 'error' => $e->getMessage(), ]); - return null; + return PublishOutcome::failure($e->getMessage()); } } } diff --git a/app/Services/Publishing/PublishOutcome.php b/app/Services/Publishing/PublishOutcome.php new file mode 100644 index 00000000..4584908e --- /dev/null +++ b/app/Services/Publishing/PublishOutcome.php @@ -0,0 +1,49 @@ +publication !== null; + } + + public function wasSkipped(): bool + { + return $this->skipped; + } + + public function failed(): bool + { + return ! $this->succeeded() && ! $this->skipped; + } +} diff --git a/database/factories/PlatformChannelFactory.php b/database/factories/PlatformChannelFactory.php index e087cd7a..c5e3bac0 100644 --- a/database/factories/PlatformChannelFactory.php +++ b/database/factories/PlatformChannelFactory.php @@ -18,7 +18,7 @@ public function definition(): array { return [ 'platform_instance_id' => PlatformInstance::factory(), - 'channel_id' => $this->faker->slug(2), + 'channel_id' => $this->faker->unique()->numberBetween(1, 999999), 'name' => $this->faker->words(2, true), 'display_name' => $this->faker->words(2, true), 'language_id' => Language::factory(), @@ -39,7 +39,6 @@ public function community(?string $name = null): static $communityName = $name ?: $this->faker->word(); return $this->state(fn (array $attributes) => [ - 'channel_id' => strtolower($communityName), 'name' => $communityName, 'display_name' => ucfirst($communityName), ]); diff --git a/database/migrations/2024_01_01_000013_key_platform_channel_posts_by_local_channel.php b/database/migrations/2024_01_01_000013_key_platform_channel_posts_by_local_channel.php new file mode 100644 index 00000000..7adc6548 --- /dev/null +++ b/database/migrations/2024_01_01_000013_key_platform_channel_posts_by_local_channel.php @@ -0,0 +1,83 @@ +dropUnique('channel_post_unique'); + $table->dropIndex(['platform', 'channel_id', 'url']); + $table->dropIndex(['platform', 'channel_id', 'title']); + $table->unsignedBigInteger('platform_channel_id')->nullable()->after('id'); + }); + + // A name shared by two instances is ambiguous; those rows stay unmapped. + DB::table('platform_channel_posts')->orderBy('id')->chunkById(200, function ($rows) { + foreach ($rows as $row) { + $matches = DB::table('platform_channels') + ->where('name', $row->channel_name) + ->orWhere('channel_id', $row->channel_name) + ->pluck('id'); + + if ($matches->count() !== 1) { + continue; + } + + DB::table('platform_channel_posts') + ->where('id', $row->id) + ->update(['platform_channel_id' => $matches->first()]); + } + }); + + // Unmappable rows are discarded rather than guessed: the mirror is a + // cache SyncChannelPostsJob rebuilds every ten minutes. + DB::table('platform_channel_posts')->whereNull('platform_channel_id')->delete(); + + Schema::table('platform_channel_posts', function (Blueprint $table) { + $table->unsignedBigInteger('platform_channel_id')->nullable(false)->change(); + $table->dropColumn(['platform', 'channel_id', 'channel_name']); + }); + + Schema::table('platform_channel_posts', function (Blueprint $table) { + $table->foreign('platform_channel_id')->references('id')->on('platform_channels')->onDelete('cascade'); + $table->unique(['platform_channel_id', 'post_id'], 'channel_post_unique'); + $table->index(['platform_channel_id', 'url']); + $table->index(['platform_channel_id', 'title']); + }); + } + + public function down(): void + { + Schema::table('platform_channel_posts', function (Blueprint $table) { + $table->dropForeign(['platform_channel_id']); + $table->dropUnique('channel_post_unique'); + $table->dropIndex(['platform_channel_id', 'url']); + $table->dropIndex(['platform_channel_id', 'title']); + $table->string('platform')->default('lemmy'); + $table->string('channel_id')->default(''); + $table->string('channel_name')->nullable(); + }); + + DB::table('platform_channel_posts')->update([ + 'channel_id' => DB::raw('platform_channel_id'), + ]); + + Schema::table('platform_channel_posts', function (Blueprint $table) { + $table->dropColumn('platform_channel_id'); + $table->unique(['platform', 'channel_id', 'post_id'], 'channel_post_unique'); + $table->index(['platform', 'channel_id', 'url']); + $table->index(['platform', 'channel_id', 'title']); + }); + } +}; diff --git a/database/migrations/2024_01_01_000014_add_skipped_to_route_articles_publish_status.php b/database/migrations/2024_01_01_000014_add_skipped_to_route_articles_publish_status.php new file mode 100644 index 00000000..ae4bd839 --- /dev/null +++ b/database/migrations/2024_01_01_000014_add_skipped_to_route_articles_publish_status.php @@ -0,0 +1,35 @@ +enum('publish_status', ['unpublished', 'publishing', 'published', 'skipped', 'error']) + ->default('unpublished') + ->change(); + }); + } + + public function down(): void + { + DB::table('route_articles') + ->where('publish_status', 'skipped') + ->update(['publish_status' => 'unpublished']); + + Schema::table('route_articles', function (Blueprint $table) { + $table->enum('publish_status', ['unpublished', 'publishing', 'published', 'error']) + ->default('unpublished') + ->change(); + }); + } +}; diff --git a/database/migrations/2024_01_01_000015_store_numeric_community_id_on_platform_channels.php b/database/migrations/2024_01_01_000015_store_numeric_community_id_on_platform_channels.php new file mode 100644 index 00000000..4f907e4f --- /dev/null +++ b/database/migrations/2024_01_01_000015_store_numeric_community_id_on_platform_channels.php @@ -0,0 +1,99 @@ +orderBy('id') + ->get() + ->mapWithKeys(fn (object $channel) => [$channel->id => $this->resolve($channel)]); + + Schema::table('platform_channels', function (Blueprint $table) { + $table->dropUnique('platform_channels_channel_id_unique'); + }); + + Schema::table('platform_channels', function (Blueprint $table) { + $table->unsignedBigInteger('remote_community_id')->nullable()->after('channel_id'); + }); + + foreach ($resolved as $id => $communityId) { + DB::table('platform_channels') + ->where('id', $id) + ->update(['remote_community_id' => $communityId]); + } + + Schema::table('platform_channels', function (Blueprint $table) { + $table->dropColumn('channel_id'); + }); + + Schema::table('platform_channels', function (Blueprint $table) { + $table->renameColumn('remote_community_id', 'channel_id'); + }); + + Schema::table('platform_channels', function (Blueprint $table) { + $table->unsignedBigInteger('channel_id')->nullable(false)->change(); + $table->unique(['platform_instance_id', 'channel_id'], 'platform_channels_channel_id_unique'); + }); + } + + public function down(): void + { + Schema::table('platform_channels', function (Blueprint $table) { + $table->dropUnique('platform_channels_channel_id_unique'); + }); + + Schema::table('platform_channels', function (Blueprint $table) { + $table->string('channel_id')->change(); + }); + + DB::table('platform_channels')->update(['channel_id' => DB::raw('name')]); + + Schema::table('platform_channels', function (Blueprint $table) { + $table->unique(['platform_instance_id', 'channel_id'], 'platform_channels_channel_id_unique'); + }); + } + + private function resolve(object $channel): int + { + if (is_numeric($channel->channel_id)) { + return (int) $channel->channel_id; + } + + $instance = DB::table('platform_instances')->find($channel->platform_instance_id); + + if (! $instance) { + throw new RuntimeException("Channel {$channel->id} has no platform instance; cannot resolve its community id."); + } + + $account = PlatformAccount::where('instance_url', $instance->url) + ->where('is_active', true) + ->first(); + + if (! $account) { + throw new RuntimeException("No active account for {$instance->url}; cannot resolve community '{$channel->channel_id}'."); + } + + $api = new LemmyApiService($instance->url); + $token = $api->login($account->username, $account->password); + + if (! $token) { + throw new RuntimeException("Could not authenticate against {$instance->url} to resolve community '{$channel->channel_id}'."); + } + + return $api->getCommunityId($channel->channel_id, $token); + } +}; diff --git a/docker/build/entrypoint.sh b/docker/build/entrypoint.sh deleted file mode 100644 index 151124eb..00000000 --- a/docker/build/entrypoint.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh - -# Exit on any error -set -e - -# Check required Lemmy environment variables -if [ -z "$LEMMY_INSTANCE" ] || [ -z "$LEMMY_USERNAME" ] || [ -z "$LEMMY_PASSWORD" ] || [ -z "$LEMMY_COMMUNITY" ]; then - echo "ERROR: Missing required Lemmy configuration variables:" - echo " LEMMY_INSTANCE=${LEMMY_INSTANCE:-'(not set)'}" - echo " LEMMY_USERNAME=${LEMMY_USERNAME:-'(not set)'}" - echo " LEMMY_PASSWORD=${LEMMY_PASSWORD:-'(not set)'}" - echo " LEMMY_COMMUNITY=${LEMMY_COMMUNITY:-'(not set)'}" - echo "Please set all required environment variables before starting the application." - exit 1 -fi - -# Wait for database to be ready -echo "Waiting for database connection..." -until php /docker/wait-for-db.php > /dev/null 2>&1; do - echo "Database not ready, waiting..." - sleep 5 -done -echo "Database connection established." - -# Wait for Redis to be ready -echo "Waiting for Redis connection..." -until php /docker/wait-for-redis.php > /dev/null 2>&1; do - echo "Redis not ready, waiting..." - sleep 2 -done -echo "Redis connection established." - -# Substitute environment variables in .env file -echo "Configuring environment variables..." -envsubst < .env > .env.tmp && mv .env.tmp .env - -# Run migrations and initial setup -echo "Running database migrations..." -php artisan migrate --force - -echo "Dispatching initial sync job..." -php artisan tinker --execute="App\\Jobs\\SyncChannelPostsJob::dispatchForLemmy();" - -# Start all services in single container -echo "Starting web server, scheduler, and Horizon..." -php artisan schedule:work & -php artisan horizon & -php artisan serve --host=0.0.0.0 --port=8000 & - -# Wait for any process to exit -wait \ No newline at end of file diff --git a/docker/build/laravel.env b/docker/build/laravel.env deleted file mode 100644 index fab9ef82..00000000 --- a/docker/build/laravel.env +++ /dev/null @@ -1,59 +0,0 @@ -APP_NAME="Lemmy Poster" -APP_ENV=production -APP_KEY= -APP_DEBUG=true -APP_URL=http://localhost - -APP_LOCALE=en -APP_FALLBACK_LOCALE=en -APP_FAKER_LOCALE=en_US - -APP_MAINTENANCE_DRIVER=file - -PHP_CLI_SERVER_WORKERS=4 - -BCRYPT_ROUNDS=12 - -LOG_CHANNEL=stack -LOG_STACK=single -LOG_DEPRECATIONS_CHANNEL=null -LOG_LEVEL=error - -DB_CONNECTION=mysql -DB_HOST=mysql -DB_PORT=3306 -DB_DATABASE=$DB_DATABASE -DB_USERNAME=$DB_USERNAME -DB_PASSWORD=$DB_PASSWORD - -SESSION_DRIVER=redis -SESSION_LIFETIME=120 -SESSION_ENCRYPT=false -SESSION_PATH=/ -SESSION_DOMAIN=null - -BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local -QUEUE_CONNECTION=redis - -CACHE_STORE=redis - -REDIS_CLIENT=phpredis -REDIS_HOST=redis -REDIS_PASSWORD=null -REDIS_PORT=6379 - -MAIL_MAILER=log -MAIL_SCHEME=null -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" - -# LEMMY SETTINGS -LEMMY_INSTANCE= -LEMMY_USERNAME= -LEMMY_PASSWORD= -LEMMY_COMMUNITY= diff --git a/docker/build/wait-for-db.php b/docker/build/wait-for-db.php deleted file mode 100644 index 2427fa84..00000000 --- a/docker/build/wait-for-db.php +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env php -connect('redis', 6379); - echo 'Connected'; - exit(0); -} catch (Exception $e) { - exit(1); -} \ No newline at end of file diff --git a/resources/views/livewire/channels.blade.php b/resources/views/livewire/channels.blade.php index 26140bae..88af793b 100644 --- a/resources/views/livewire/channels.blade.php +++ b/resources/views/livewire/channels.blade.php @@ -162,22 +162,11 @@ class="w-full inline-flex justify-center rounded-md border border-gray-300 shado @if ($showCreateModal)
-
- - - @error('newName')

{{ $message }}

@enderror -
-
@error('newPlatformInstanceId')

{{ $message }}

@enderror + @if ($communityLoadError) +

{{ $communityLoadError }}

+ @endif
+ @if ($availableCommunities) +
+
+ + +
+ + @error('newCommunityId')

{{ $message }}

@enderror +
+ @endif +
-

Enter the community name (without the @ or instance)

- @error('channelName')

{{ $message }}

@enderror -
-
@error('platformInstanceId')

{{ $message }}

@enderror + @if ($communityLoadError) +

{{ $communityLoadError }}

+ @endif
+ @if ($availableCommunities) +
+
+ + +
+ + @error('channelCommunityId')

{{ $message }}

@enderror +
+ @endif +