fedi-feed-router/backend/app/Http/Controllers/Api/V1/OnboardingController.php

354 lines
12 KiB
PHP
Raw Normal View History

2025-08-09 00:03:45 +02:00
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Requests\StoreFeedRequest;
use App\Http\Resources\FeedResource;
use App\Http\Resources\PlatformAccountResource;
use App\Http\Resources\PlatformChannelResource;
2025-08-09 13:48:25 +02:00
use App\Http\Resources\RouteResource;
2025-08-09 00:03:45 +02:00
use App\Models\Feed;
use App\Models\Language;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
2025-08-09 13:48:25 +02:00
use App\Models\Route;
use App\Models\Setting;
2025-08-09 00:03:45 +02:00
use App\Services\Auth\LemmyAuthService;
2025-08-09 18:34:19 +02:00
use App\Jobs\ArticleDiscoveryJob;
2025-08-09 00:03:45 +02:00
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class OnboardingController extends BaseController
{
public function __construct(
private readonly LemmyAuthService $lemmyAuthService
) {}
/**
* Get onboarding status - whether user needs onboarding
*/
public function status(): JsonResponse
{
$hasPlatformAccount = PlatformAccount::where('is_active', true)->exists();
$hasFeed = Feed::where('is_active', true)->exists();
$hasChannel = PlatformChannel::where('is_active', true)->exists();
2025-08-09 13:48:25 +02:00
$hasRoute = Route::where('is_active', true)->exists();
// Check if onboarding was explicitly skipped
$onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true';
2025-08-09 00:03:45 +02:00
// User needs onboarding if they don't have the required components AND haven't skipped it
2025-08-09 13:48:25 +02:00
$needsOnboarding = (!$hasPlatformAccount || !$hasFeed || !$hasChannel || !$hasRoute) && !$onboardingSkipped;
2025-08-09 00:03:45 +02:00
// Determine current step
$currentStep = null;
if ($needsOnboarding) {
if (!$hasPlatformAccount) {
$currentStep = 'platform';
} elseif (!$hasFeed) {
$currentStep = 'feed';
} elseif (!$hasChannel) {
$currentStep = 'channel';
2025-08-09 13:48:25 +02:00
} elseif (!$hasRoute) {
$currentStep = 'route';
2025-08-09 00:03:45 +02:00
}
}
return $this->sendResponse([
'needs_onboarding' => $needsOnboarding,
'current_step' => $currentStep,
'has_platform_account' => $hasPlatformAccount,
'has_feed' => $hasFeed,
'has_channel' => $hasChannel,
2025-08-09 13:48:25 +02:00
'has_route' => $hasRoute,
'onboarding_skipped' => $onboardingSkipped,
2025-08-09 00:03:45 +02:00
], 'Onboarding status retrieved successfully.');
}
/**
* Get onboarding options (languages, platform instances)
*/
public function options(): JsonResponse
{
$languages = Language::where('is_active', true)
->orderBy('name')
->get(['id', 'short_code', 'name', 'native_name', 'is_active']);
$platformInstances = PlatformInstance::where('is_active', true)
->orderBy('name')
->get(['id', 'platform', 'url', 'name', 'description', 'is_active']);
2025-08-09 13:48:25 +02:00
// Get existing feeds and channels for route creation
$feeds = Feed::where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'url', 'type']);
$platformChannels = PlatformChannel::where('is_active', true)
->with(['platformInstance:id,name,url'])
->orderBy('name')
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
// Expose supported feed providers so frontend can offer only VRT and Belga
$feedProviders = [
['code' => 'vrt', 'name' => (new \App\Services\Parsers\VrtHomepageParserAdapter())->getSourceName()],
['code' => 'belga', 'name' => (new \App\Services\Parsers\BelgaHomepageParserAdapter())->getSourceName()],
];
2025-08-09 00:03:45 +02:00
return $this->sendResponse([
'languages' => $languages,
'platform_instances' => $platformInstances,
2025-08-09 13:48:25 +02:00
'feeds' => $feeds,
'platform_channels' => $platformChannels,
'feed_providers' => $feedProviders,
2025-08-09 00:03:45 +02:00
], 'Onboarding options retrieved successfully.');
}
/**
* Create platform account for onboarding
*/
public function createPlatform(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'instance_url' => 'required|string|max:255|regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/',
2025-08-09 00:03:45 +02:00
'username' => 'required|string|max:255',
'password' => 'required|string|min:6',
'platform' => 'required|in:lemmy',
], [
'instance_url.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)'
2025-08-09 00:03:45 +02:00
]);
if ($validator->fails()) {
throw new ValidationException($validator);
}
$validated = $validator->validated();
// Normalize the instance URL - prepend https:// if needed
$instanceDomain = $validated['instance_url'];
$fullInstanceUrl = 'https://' . $instanceDomain;
2025-08-09 00:03:45 +02:00
try {
// Create or get platform instance
$platformInstance = PlatformInstance::firstOrCreate([
'url' => $fullInstanceUrl,
2025-08-09 00:03:45 +02:00
'platform' => $validated['platform'],
], [
'name' => ucfirst($instanceDomain),
2025-08-09 00:03:45 +02:00
'is_active' => true,
]);
// Authenticate with Lemmy API using the full URL
2025-08-09 00:03:45 +02:00
$authResponse = $this->lemmyAuthService->authenticate(
$fullInstanceUrl,
2025-08-09 00:03:45 +02:00
$validated['username'],
$validated['password']
);
// Create platform account with the current schema
$platformAccount = PlatformAccount::create([
'platform' => $validated['platform'],
'instance_url' => $fullInstanceUrl,
2025-08-09 00:03:45 +02:00
'username' => $validated['username'],
'password' => $validated['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,
2025-08-09 13:48:25 +02:00
'api_token' => $authResponse['jwt'] ?? null, // Store JWT in settings for now
2025-08-09 00:03:45 +02:00
],
'is_active' => true,
'status' => 'active',
]);
return $this->sendResponse(
new PlatformAccountResource($platformAccount),
'Platform account created successfully.'
);
2025-08-09 00:52:14 +02:00
} catch (\App\Exceptions\PlatformAuthException $e) {
2025-08-09 13:48:25 +02:00
// Check if it's a rate limit error
if (str_contains($e->getMessage(), 'Rate limited by')) {
return $this->sendError($e->getMessage(), [], 429);
}
2025-08-09 00:52:14 +02:00
return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422);
2025-08-09 00:03:45 +02:00
} catch (\Exception $e) {
2025-08-09 00:52:14 +02:00
$message = 'Unable to connect to the Lemmy instance. Please check the URL and try again.';
2025-08-09 00:52:14 +02:00
// If it's a network/connection issue, provide a more specific message
if (str_contains(strtolower($e->getMessage()), 'connection') ||
2025-08-09 00:52:14 +02:00
str_contains(strtolower($e->getMessage()), 'network') ||
str_contains(strtolower($e->getMessage()), 'timeout')) {
$message = 'Connection failed. Please check the instance URL and your internet connection.';
}
2025-08-09 00:52:14 +02:00
return $this->sendError($message, [], 422);
2025-08-09 00:03:45 +02:00
}
}
/**
* Create feed for onboarding
*/
public function createFeed(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'provider' => 'required|string|in:vrt,belga',
2025-08-09 00:03:45 +02:00
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string|max:1000',
]);
if ($validator->fails()) {
throw new ValidationException($validator);
}
$validated = $validator->validated();
// Supported providers and their canonical definitions
$providers = [
'vrt' => new \App\Services\Parsers\VrtHomepageParserAdapter(),
'belga' => new \App\Services\Parsers\BelgaHomepageParserAdapter(),
];
$adapter = $providers[$validated['provider']];
$finalUrl = $adapter->getHomepageUrl();
$finalName = $validated['name'];
// Keep user provided name, but default to provider name if empty/blank
if (trim($finalName) === '') {
$finalName = $adapter->getSourceName();
}
2025-08-09 00:03:45 +02:00
$feed = Feed::create([
'name' => $finalName,
'url' => $finalUrl,
'type' => 'website',
2025-08-09 00:03:45 +02:00
'language_id' => $validated['language_id'],
'description' => $validated['description'] ?? null,
'is_active' => true,
]);
return $this->sendResponse(
new FeedResource($feed->load('language')),
'Feed created successfully.'
);
}
/**
* Create channel for onboarding
*/
public function createChannel(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'platform_instance_id' => 'required|exists:platform_instances,id',
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string|max:1000',
]);
if ($validator->fails()) {
throw new ValidationException($validator);
}
$validated = $validator->validated();
$channel = PlatformChannel::create([
'platform_instance_id' => $validated['platform_instance_id'],
'channel_id' => $validated['name'], // For Lemmy, this is the community name
'name' => $validated['name'],
'display_name' => ucfirst($validated['name']),
'description' => $validated['description'] ?? null,
'language_id' => $validated['language_id'],
'is_active' => true,
]);
return $this->sendResponse(
new PlatformChannelResource($channel->load(['platformInstance', 'language'])),
'Channel created successfully.'
);
}
2025-08-09 13:48:25 +02:00
/**
* Create route for onboarding
*/
public function createRoute(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'feed_id' => 'required|exists:feeds,id',
'platform_channel_id' => 'required|exists:platform_channels,id',
'priority' => 'nullable|integer|min:1|max:100',
'filters' => 'nullable|array',
]);
if ($validator->fails()) {
throw new ValidationException($validator);
}
$validated = $validator->validated();
$route = Route::create([
'feed_id' => $validated['feed_id'],
'platform_channel_id' => $validated['platform_channel_id'],
'priority' => $validated['priority'] ?? 50,
'filters' => $validated['filters'] ?? [],
'is_active' => true,
]);
return $this->sendResponse(
new RouteResource($route->load(['feed', 'platformChannel'])),
'Route created successfully.'
);
}
2025-08-09 00:03:45 +02:00
/**
* Mark onboarding as complete
*/
public function complete(): JsonResponse
{
2025-08-09 18:34:19 +02:00
// If user has created feeds during onboarding, start article discovery
$hasFeed = Feed::where('is_active', true)->exists();
if ($hasFeed) {
ArticleDiscoveryJob::dispatch();
}
2025-08-09 00:03:45 +02:00
return $this->sendResponse(
2025-08-09 18:34:19 +02:00
['completed' => true, 'article_refresh_triggered' => $hasFeed],
2025-08-09 00:03:45 +02:00
'Onboarding completed successfully.'
);
}
/**
* Skip onboarding - user can access the app without completing setup
*/
public function skip(): JsonResponse
{
Setting::updateOrCreate(
['key' => 'onboarding_skipped'],
['value' => 'true']
);
return $this->sendResponse(
['skipped' => true],
'Onboarding skipped successfully.'
);
}
/**
* Reset onboarding skip status - force user back to onboarding
*/
public function resetSkip(): JsonResponse
{
Setting::where('key', 'onboarding_skipped')->delete();
return $this->sendResponse(
['reset' => true],
'Onboarding skip status reset successfully.'
);
}
}