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/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/SyncChannelPostsJob.php b/app/Jobs/SyncChannelPostsJob.php index 96043a2e..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, $this->channel, $communityId); + $api->syncChannelPosts($token, $this->channel, $this->channel->channel_id); $logSaver->info('Channel posts synced successfully', $this->channel); } catch (Exception $e) { 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/Modules/Lemmy/Services/LemmyApiService.php b/app/Modules/Lemmy/Services/LemmyApiService.php index c3d23c5c..0a4dba47 100644 --- a/app/Modules/Lemmy/Services/LemmyApiService.php +++ b/app/Modules/Lemmy/Services/LemmyApiService.php @@ -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 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/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_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/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 +