fedi-feed-router/app/Livewire/Onboarding.php

466 lines
16 KiB
PHP
Raw Normal View History

<?php
namespace App\Livewire;
use App\Jobs\ArticleDiscoveryJob;
use App\Jobs\SyncChannelPostsJob;
use App\Models\Feed;
use App\Models\Language;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use App\Models\Route;
use App\Models\Setting;
use App\Services\Auth\LemmyAuthService;
use App\Services\OnboardingService;
use Illuminate\Support\Facades\Crypt;
use Livewire\Component;
class Onboarding extends Component
{
// Step tracking (1-6: welcome, platform, channel, feed, route, complete)
public int $step = 1;
// Platform form
public string $instanceUrl = '';
public string $username = '';
public string $password = '';
public ?array $existingAccount = null;
// Feed form
public string $feedName = '';
public string $feedProvider = 'vrt';
public ?int $feedLanguageId = null;
public string $feedDescription = '';
// Channel form
public string $channelName = '';
public ?int $platformInstanceId = null;
public ?int $channelLanguageId = null;
public string $channelDescription = '';
// Route form
public ?int $routeFeedId = null;
public ?int $routeChannelId = null;
public int $routePriority = 50;
// State
2026-01-23 00:56:01 +01:00
public array $formErrors = [];
public bool $isLoading = false;
private ?int $previousChannelLanguageId = null;
protected LemmyAuthService $lemmyAuthService;
public function boot(LemmyAuthService $lemmyAuthService): void
{
$this->lemmyAuthService = $lemmyAuthService;
}
public function mount(): void
{
// Check for existing platform account
$account = PlatformAccount::where('is_active', true)->first();
if ($account) {
$this->existingAccount = [
'id' => $account->id,
'username' => $account->username,
'instance_url' => $account->instance_url,
];
}
// Pre-fill feed form if exists
$feed = Feed::where('is_active', true)->first();
if ($feed) {
$this->feedName = $feed->name;
$this->feedProvider = $feed->provider ?? 'vrt';
$this->feedLanguageId = $feed->language_id;
$this->feedDescription = $feed->description ?? '';
}
// 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 ?? '';
}
// Pre-fill route form if exists
$route = Route::where('is_active', true)->first();
if ($route) {
$this->routeFeedId = $route->feed_id;
$this->routeChannelId = $route->platform_channel_id;
$this->routePriority = $route->priority;
}
}
public function goToStep(int $step): void
{
$this->step = $step;
2026-01-23 00:56:01 +01:00
$this->formErrors = [];
}
public function nextStep(): void
{
$this->step++;
2026-01-23 00:56:01 +01:00
$this->formErrors = [];
// When entering feed step, inherit language from channel
if ($this->step === 4 && $this->channelLanguageId) {
$this->feedLanguageId = $this->channelLanguageId;
}
}
public function previousStep(): void
{
if ($this->step > 1) {
$this->step--;
2026-01-23 00:56:01 +01:00
$this->formErrors = [];
}
}
public function continueWithExistingAccount(): void
{
$this->nextStep();
}
public function deleteAccount(): void
{
if ($this->existingAccount) {
PlatformAccount::destroy($this->existingAccount['id']);
$this->existingAccount = null;
}
}
public function createPlatformAccount(): void
{
2026-01-23 00:56:01 +01:00
$this->formErrors = [];
$this->isLoading = true;
$this->validate([
'instanceUrl' => 'required|string|max:255|regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/',
'username' => 'required|string|max:255',
'password' => 'required|string|min:6',
], [
'instanceUrl.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)',
]);
$fullInstanceUrl = 'https://' . $this->instanceUrl;
try {
// Authenticate with Lemmy API first (before creating any records)
$authResponse = $this->lemmyAuthService->authenticate(
$fullInstanceUrl,
$this->username,
$this->password
);
// Only create platform instance after successful authentication
$platformInstance = PlatformInstance::firstOrCreate([
'url' => $fullInstanceUrl,
'platform' => 'lemmy',
], [
'name' => ucfirst($this->instanceUrl),
'is_active' => true,
]);
// Create platform account
$platformAccount = PlatformAccount::create([
'platform' => 'lemmy',
'instance_url' => $fullInstanceUrl,
'username' => $this->username,
'password' => Crypt::encryptString($this->password),
'settings' => [
'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
'description' => $authResponse['person_view']['person']['bio'] ?? null,
'person_id' => $authResponse['person_view']['person']['id'] ?? null,
'platform_instance_id' => $platformInstance->id,
'api_token' => $authResponse['jwt'] ?? null,
],
'is_active' => true,
'status' => 'active',
]);
$this->existingAccount = [
'id' => $platformAccount->id,
'username' => $platformAccount->username,
'instance_url' => $platformAccount->instance_url,
];
$this->nextStep();
} catch (\App\Exceptions\PlatformAuthException $e) {
2026-02-25 20:22:02 +01:00
$message = $e->getMessage();
if (str_contains($message, 'Rate limited by')) {
$this->formErrors['general'] = $message;
} elseif (str_contains($message, 'Connection failed')) {
$this->formErrors['general'] = 'Unable to connect to the Lemmy instance. Please check the URL and try again.';
} else {
2026-01-23 00:56:01 +01:00
$this->formErrors['general'] = 'Invalid username or password. Please check your credentials and try again.';
}
} catch (\Exception $e) {
2026-02-25 20:22:02 +01:00
logger()->error('Lemmy platform account creation failed', [
'instance_url' => $fullInstanceUrl,
'username' => $this->username,
'error' => $e->getMessage(),
'class' => get_class($e),
]);
$this->formErrors['general'] = 'An error occurred while setting up your account. Please try again.';
} finally {
$this->isLoading = false;
}
}
public function createFeed(): void
{
2026-01-23 00:56:01 +01:00
$this->formErrors = [];
$this->isLoading = true;
// Get available provider codes for validation
$availableProviders = collect($this->getProvidersForLanguage())->pluck('code')->implode(',');
$this->validate([
'feedName' => 'required|string|max:255',
'feedProvider' => "required|in:{$availableProviders}",
'feedLanguageId' => 'required|exists:languages,id',
'feedDescription' => 'nullable|string|max:1000',
]);
try {
// Get language short code
$language = Language::find($this->feedLanguageId);
$langCode = $language->short_code;
// Look up URL from config
$url = config("feed.providers.{$this->feedProvider}.languages.{$langCode}.url");
if (!$url) {
$this->formErrors['general'] = 'Invalid provider and language combination.';
$this->isLoading = false;
return;
}
$providerConfig = config("feed.providers.{$this->feedProvider}");
Feed::firstOrCreate(
['url' => $url],
[
'name' => $this->feedName,
'type' => $providerConfig['type'] ?? 'website',
'provider' => $this->feedProvider,
'language_id' => $this->feedLanguageId,
'description' => $this->feedDescription ?: null,
'is_active' => true,
]
);
$this->nextStep();
} catch (\Exception $e) {
2026-01-23 00:56:01 +01:00
$this->formErrors['general'] = 'Failed to create feed. Please try again.';
} finally {
$this->isLoading = false;
}
}
public function createChannel(): void
{
2026-01-23 00:56:01 +01:00
$this->formErrors = [];
$this->isLoading = true;
$this->validate([
'channelName' => 'required|string|max:255',
'platformInstanceId' => 'required|exists:platform_instances,id',
'channelLanguageId' => 'required|exists:languages,id',
'channelDescription' => 'nullable|string|max:1000',
]);
// If language changed, reset feed form
if ($this->previousChannelLanguageId !== null && $this->previousChannelLanguageId !== $this->channelLanguageId) {
$this->feedName = '';
$this->feedProvider = '';
$this->feedDescription = '';
$this->routeFeedId = null;
$this->routeChannelId = null;
}
$this->previousChannelLanguageId = $this->channelLanguageId;
try {
$platformInstance = PlatformInstance::findOrFail($this->platformInstanceId);
// Check for active platform accounts
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
->where('is_active', true)
->get();
if ($activeAccounts->isEmpty()) {
2026-01-23 00:56:01 +01:00
$this->formErrors['general'] = 'No active platform accounts found for this instance. Please create a platform account first.';
$this->isLoading = false;
return;
}
$channel = PlatformChannel::create([
'platform_instance_id' => $this->platformInstanceId,
'channel_id' => $this->channelName,
'name' => $this->channelName,
'display_name' => ucfirst($this->channelName),
'description' => $this->channelDescription ?: null,
'language_id' => $this->channelLanguageId,
'is_active' => true,
]);
// Attach first active account
$channel->platformAccounts()->attach($activeAccounts->first()->id, [
'is_active' => true,
'priority' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
// Sync existing posts from this channel for duplicate detection
SyncChannelPostsJob::dispatch($channel);
$this->nextStep();
} catch (\Exception $e) {
2026-01-23 00:56:01 +01:00
$this->formErrors['general'] = 'Failed to create channel. Please try again.';
} finally {
$this->isLoading = false;
}
}
public function createRoute(): void
{
2026-01-23 00:56:01 +01:00
$this->formErrors = [];
$this->isLoading = true;
$this->validate([
'routeFeedId' => 'required|exists:feeds,id',
'routeChannelId' => 'required|exists:platform_channels,id',
'routePriority' => 'nullable|integer|min:1|max:100',
]);
try {
Route::create([
'feed_id' => $this->routeFeedId,
'platform_channel_id' => $this->routeChannelId,
'priority' => $this->routePriority,
'is_active' => true,
]);
// Trigger article discovery
ArticleDiscoveryJob::dispatch();
$this->nextStep();
} catch (\Exception $e) {
2026-01-23 00:56:01 +01:00
$this->formErrors['general'] = 'Failed to create route. Please try again.';
} finally {
$this->isLoading = false;
}
}
public function completeOnboarding(): void
{
Setting::updateOrCreate(
['key' => 'onboarding_completed'],
['value' => now()->toIso8601String()]
);
app(OnboardingService::class)->clearCache();
$this->redirect(route('dashboard'));
}
/**
* Get language codes that have at least one active provider.
*/
public function getAvailableLanguageCodes(): array
{
$providers = config('feed.providers', []);
$languageCodes = [];
foreach ($providers as $provider) {
if (!($provider['is_active'] ?? false)) {
continue;
}
foreach (array_keys($provider['languages'] ?? []) as $code) {
$languageCodes[$code] = true;
}
}
return array_keys($languageCodes);
}
/**
* Get providers available for the current channel language.
*/
public function getProvidersForLanguage(): array
{
if (!$this->channelLanguageId) {
return [];
}
$language = Language::find($this->channelLanguageId);
if (!$language) {
return [];
}
$langCode = $language->short_code;
$providers = config('feed.providers', []);
$available = [];
foreach ($providers as $key => $provider) {
if (!($provider['is_active'] ?? false)) {
continue;
}
if (isset($provider['languages'][$langCode])) {
$available[] = [
'code' => $provider['code'],
'name' => $provider['name'],
'description' => $provider['description'] ?? '',
];
}
}
return $available;
}
/**
* Get the current channel language model.
*/
public function getChannelLanguage(): ?Language
{
if (!$this->channelLanguageId) {
return null;
}
return Language::find($this->channelLanguageId);
}
public function render()
{
// For channel step: only show languages that have providers
$availableCodes = $this->getAvailableLanguageCodes();
$wizardLanguages = Language::where('is_active', true)
->whereIn('short_code', $availableCodes)
->orderBy('name')
->get();
$platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get();
$feeds = Feed::with('language')->where('is_active', true)->orderBy('name')->get();
$channels = PlatformChannel::with('language')->where('is_active', true)->orderBy('name')->get();
// For feed step: only show providers for the channel's language
$feedProviders = collect($this->getProvidersForLanguage());
// Get channel language for display
$channelLanguage = $this->getChannelLanguage();
return view('livewire.onboarding', [
'wizardLanguages' => $wizardLanguages,
'platformInstances' => $platformInstances,
'feeds' => $feeds,
'channels' => $channels,
'feedProviders' => $feedProviders,
'channelLanguage' => $channelLanguage,
2026-01-23 00:56:01 +01:00
])->layout('layouts.onboarding');
}
}