From 2a68895ba953eccff4901555c0a6bbc5ce7a92af Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 9 Aug 2025 13:48:25 +0200 Subject: [PATCH] Minor fixes --- backend/.env.broken | 62 +++++++ .../Api/V1/OnboardingController.php | 62 ++++++- backend/app/Modules/Lemmy/LemmyRequest.php | 53 ++++-- .../Lemmy/Services/LemmyApiService.php | 66 +++++-- .../app/Services/Auth/LemmyAuthService.php | 4 + .../app/Services/DashboardStatsService.php | 17 +- backend/database/seeders/DatabaseSeeder.php | 5 +- backend/database/seeders/LanguageSeeder.php | 29 +++ backend/routes/api.php | 1 + .../Api/V1/OnboardingControllerTest.php | 70 +++++++ docker/dev/podman/container-start.sh | 29 ++- frontend/src/contexts/OnboardingContext.tsx | 10 +- frontend/src/lib/api.ts | 33 +++- frontend/src/main.tsx | 5 +- frontend/src/pages/Dashboard.tsx | 126 ++++++------- .../src/pages/onboarding/OnboardingWizard.tsx | 2 + .../pages/onboarding/steps/ChannelStep.tsx | 7 +- .../pages/onboarding/steps/CompleteStep.tsx | 10 +- .../src/pages/onboarding/steps/FeedStep.tsx | 4 +- .../src/pages/onboarding/steps/RouteStep.tsx | 174 ++++++++++++++++++ .../pages/onboarding/steps/WelcomeStep.tsx | 4 + 21 files changed, 650 insertions(+), 123 deletions(-) create mode 100644 backend/.env.broken create mode 100644 backend/database/seeders/LanguageSeeder.php create mode 100644 frontend/src/pages/onboarding/steps/RouteStep.tsx diff --git a/backend/.env.broken b/backend/.env.broken new file mode 100644 index 0000000..cb03412 --- /dev/null +++ b/backend/.env.broken @@ -0,0 +1,62 @@ +APP_NAME="FFR Development" +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_TIMEZONE=UTC +APP_URL=http://localhost:8000 + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +APP_MAINTENANCE_STORE=database + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=mysql +DB_HOST=db +DB_PORT=3306 +DB_DATABASE=ffr_dev +DB_USERNAME=ffr_user +DB_PASSWORD=ffr_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 +CACHE_PREFIX= + +REDIS_CLIENT=phpredis +REDIS_HOST=redis +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/backend/app/Http/Controllers/Api/V1/OnboardingController.php b/backend/app/Http/Controllers/Api/V1/OnboardingController.php index 2553b33..c092109 100644 --- a/backend/app/Http/Controllers/Api/V1/OnboardingController.php +++ b/backend/app/Http/Controllers/Api/V1/OnboardingController.php @@ -6,11 +6,13 @@ use App\Http\Resources\FeedResource; use App\Http\Resources\PlatformAccountResource; use App\Http\Resources\PlatformChannelResource; +use App\Http\Resources\RouteResource; 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 Illuminate\Http\JsonResponse; @@ -32,12 +34,13 @@ 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(); + $hasRoute = Route::where('is_active', true)->exists(); // Check if onboarding was explicitly skipped $onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true'; // User needs onboarding if they don't have the required components AND haven't skipped it - $needsOnboarding = (!$hasPlatformAccount || !$hasFeed || !$hasChannel) && !$onboardingSkipped; + $needsOnboarding = (!$hasPlatformAccount || !$hasFeed || !$hasChannel || !$hasRoute) && !$onboardingSkipped; // Determine current step $currentStep = null; @@ -48,6 +51,8 @@ public function status(): JsonResponse $currentStep = 'feed'; } elseif (!$hasChannel) { $currentStep = 'channel'; + } elseif (!$hasRoute) { + $currentStep = 'route'; } } @@ -57,6 +62,7 @@ public function status(): JsonResponse 'has_platform_account' => $hasPlatformAccount, 'has_feed' => $hasFeed, 'has_channel' => $hasChannel, + 'has_route' => $hasRoute, 'onboarding_skipped' => $onboardingSkipped, ], 'Onboarding status retrieved successfully.'); } @@ -74,9 +80,21 @@ public function options(): JsonResponse ->orderBy('name') ->get(['id', 'platform', 'url', 'name', 'description', 'is_active']); + // 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']); + return $this->sendResponse([ 'languages' => $languages, 'platform_instances' => $platformInstances, + 'feeds' => $feeds, + 'platform_channels' => $platformChannels, ], 'Onboarding options retrieved successfully.'); } @@ -127,12 +145,12 @@ public function createPlatform(Request $request): JsonResponse 'instance_url' => $fullInstanceUrl, 'username' => $validated['username'], 'password' => $validated['password'], - 'api_token' => $authResponse['jwt'] ?? null, '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, // Store JWT in settings for now ], 'is_active' => true, 'status' => 'active', @@ -144,10 +162,14 @@ public function createPlatform(Request $request): JsonResponse ); } catch (\App\Exceptions\PlatformAuthException $e) { - // Handle authentication-specific errors with cleaner messages + // Check if it's a rate limit error + if (str_contains($e->getMessage(), 'Rate limited by')) { + return $this->sendError($e->getMessage(), [], 429); + } + return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422); } catch (\Exception $e) { - // Handle other errors (network, instance not found, etc.) + $message = 'Unable to connect to the Lemmy instance. Please check the URL and try again.'; // If it's a network/connection issue, provide a more specific message @@ -229,6 +251,38 @@ public function createChannel(Request $request): JsonResponse ); } + /** + * 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.' + ); + } + /** * Mark onboarding as complete */ diff --git a/backend/app/Modules/Lemmy/LemmyRequest.php b/backend/app/Modules/Lemmy/LemmyRequest.php index 1c6cdc2..461b60d 100644 --- a/backend/app/Modules/Lemmy/LemmyRequest.php +++ b/backend/app/Modules/Lemmy/LemmyRequest.php @@ -7,28 +7,37 @@ class LemmyRequest { - private string $instance; + private string $host; + private string $scheme; private ?string $token; public function __construct(string $instance, ?string $token = null) { // Handle both full URLs and just domain names - $this->instance = $this->normalizeInstance($instance); + [$this->scheme, $this->host] = $this->parseInstance($instance); $this->token = $token; } /** - * Normalize instance URL to just the domain name + * Parse instance into scheme and host. Defaults to https when scheme missing. + * + * @return array{0:string,1:string} [scheme, host] */ - private function normalizeInstance(string $instance): string + private function parseInstance(string $instance): array { + $scheme = 'https'; + + // If instance includes a scheme, honor it + if (preg_match('/^(https?):\/\//i', $instance, $m)) { + $scheme = strtolower($m[1]); + } + // Remove protocol if present - $instance = preg_replace('/^https?:\/\//', '', $instance); - + $host = preg_replace('/^https?:\/\//i', '', $instance); // Remove trailing slash if present - $instance = rtrim($instance, '/'); - - return $instance; + $host = rtrim($host ?? '', '/'); + + return [$scheme, $host]; } /** @@ -36,14 +45,14 @@ private function normalizeInstance(string $instance): string */ public function get(string $endpoint, array $params = []): Response { - $url = "https://{$this->instance}/api/v3/{$endpoint}"; - + $url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->host, ltrim($endpoint, '/')); + $request = Http::timeout(30); - + if ($this->token) { $request = $request->withToken($this->token); } - + return $request->get($url, $params); } @@ -52,14 +61,14 @@ public function get(string $endpoint, array $params = []): Response */ public function post(string $endpoint, array $data = []): Response { - $url = "https://{$this->instance}/api/v3/{$endpoint}"; - + $url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->host, ltrim($endpoint, '/')); + $request = Http::timeout(30); - + if ($this->token) { $request = $request->withToken($this->token); } - + return $request->post($url, $data); } @@ -68,4 +77,14 @@ public function withToken(string $token): self $this->token = $token; return $this; } + + /** + * Return a cloned request with a different scheme (http or https) + */ + public function withScheme(string $scheme): self + { + $clone = clone $this; + $clone->scheme = strtolower($scheme) === 'http' ? 'http' : 'https'; + return $clone; + } } diff --git a/backend/app/Modules/Lemmy/Services/LemmyApiService.php b/backend/app/Modules/Lemmy/Services/LemmyApiService.php index 108c431..3329703 100644 --- a/backend/app/Modules/Lemmy/Services/LemmyApiService.php +++ b/backend/app/Modules/Lemmy/Services/LemmyApiService.php @@ -18,27 +18,61 @@ public function __construct(string $instance) public function login(string $username, string $password): ?string { - try { - $request = new LemmyRequest($this->instance); - $response = $request->post('user/login', [ - 'username_or_email' => $username, - 'password' => $password, - ]); + // Try HTTPS first; on failure, optionally retry with HTTP to support dev instances + $schemesToTry = []; + if (preg_match('/^https?:\/\//i', $this->instance)) { + // Preserve user-provided scheme as first try + $schemesToTry[] = strtolower(str_starts_with($this->instance, 'http://') ? 'http' : 'https'); + } else { + // Default order: https then http + $schemesToTry = ['https', 'http']; + } - if (!$response->successful()) { - logger()->error('Lemmy login failed', [ - 'status' => $response->status(), - 'body' => $response->body() + foreach ($schemesToTry as $idx => $scheme) { + try { + $request = new LemmyRequest($this->instance); + // ensure scheme used matches current attempt + $request = $request->withScheme($scheme); + + $response = $request->post('user/login', [ + 'username_or_email' => $username, + 'password' => $password, ]); + + if (!$response->successful()) { + $responseBody = $response->body(); + logger()->error('Lemmy login failed', [ + 'status' => $response->status(), + 'body' => $responseBody, + 'scheme' => $scheme, + ]); + + // Check if it's a rate limit error + if (str_contains($responseBody, 'rate_limit_error')) { + throw new Exception('Rate limited by Lemmy instance. Please wait a moment and try again.'); + } + + // If first attempt failed and there is another scheme to try, continue loop + if ($idx === 0 && count($schemesToTry) > 1) { + continue; + } + + return null; + } + + $data = $response->json(); + return $data['jwt'] ?? null; + } catch (Exception $e) { + logger()->error('Lemmy login exception', ['error' => $e->getMessage(), 'scheme' => $scheme]); + // If this was the first attempt and HTTPS, try HTTP next + if ($idx === 0 && in_array('http', $schemesToTry, true)) { + continue; + } return null; } - - $data = $response->json(); - return $data['jwt'] ?? null; - } catch (Exception $e) { - logger()->error('Lemmy login exception', ['error' => $e->getMessage()]); - return null; } + + return null; } public function getCommunityId(string $communityName, string $token): int diff --git a/backend/app/Services/Auth/LemmyAuthService.php b/backend/app/Services/Auth/LemmyAuthService.php index b3b4fe1..c9f53a2 100644 --- a/backend/app/Services/Auth/LemmyAuthService.php +++ b/backend/app/Services/Auth/LemmyAuthService.php @@ -72,6 +72,10 @@ public function authenticate(string $instanceUrl, string $username, string $pass // Re-throw PlatformAuthExceptions as-is to avoid nesting throw $e; } catch (Exception $e) { + // Check if it's a rate limit error + if (str_contains($e->getMessage(), 'Rate limited by')) { + throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage()); + } // For other exceptions, throw a clean PlatformAuthException throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed'); } diff --git a/backend/app/Services/DashboardStatsService.php b/backend/app/Services/DashboardStatsService.php index 02b095f..a0fb821 100644 --- a/backend/app/Services/DashboardStatsService.php +++ b/backend/app/Services/DashboardStatsService.php @@ -85,10 +85,17 @@ public function getSystemStats(): array ') ->first(); + $accountStats = DB::table('platform_accounts') + ->selectRaw(' + COUNT(*) as total_platform_accounts, + SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_platform_accounts + ') + ->first(); + $channelStats = DB::table('platform_channels') ->selectRaw(' - COUNT(*) as total_channels, - SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_channels + COUNT(*) as total_platform_channels, + SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_platform_channels ') ->first(); @@ -102,8 +109,10 @@ public function getSystemStats(): array return [ 'total_feeds' => $feedStats->total_feeds, 'active_feeds' => $feedStats->active_feeds, - 'total_channels' => $channelStats->total_channels, - 'active_channels' => $channelStats->active_channels, + 'total_platform_accounts' => $accountStats->total_platform_accounts, + 'active_platform_accounts' => $accountStats->active_platform_accounts, + 'total_platform_channels' => $channelStats->total_platform_channels, + 'active_platform_channels' => $channelStats->active_platform_channels, 'total_routes' => $routeStats->total_routes, 'active_routes' => $routeStats->active_routes, ]; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index 264345a..d074784 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -8,6 +8,9 @@ class DatabaseSeeder extends Seeder { public function run(): void { - $this->call(SettingsSeeder::class); + $this->call([ + SettingsSeeder::class, + LanguageSeeder::class, + ]); } } diff --git a/backend/database/seeders/LanguageSeeder.php b/backend/database/seeders/LanguageSeeder.php new file mode 100644 index 0000000..26cde8a --- /dev/null +++ b/backend/database/seeders/LanguageSeeder.php @@ -0,0 +1,29 @@ + 'en', 'name' => 'English', 'native_name' => 'English', 'is_active' => true], + ['short_code' => 'nl', 'name' => 'Dutch', 'native_name' => 'Nederlands', 'is_active' => true], + ['short_code' => 'fr', 'name' => 'French', 'native_name' => 'Français', 'is_active' => true], + ['short_code' => 'de', 'name' => 'German', 'native_name' => 'Deutsch', 'is_active' => true], + ['short_code' => 'es', 'name' => 'Spanish', 'native_name' => 'Español', 'is_active' => true], + ['short_code' => 'it', 'name' => 'Italian', 'native_name' => 'Italiano', 'is_active' => true], + ]; + + foreach ($languages as $lang) { + Language::updateOrCreate( + ['short_code' => $lang['short_code']], + $lang + ); + } + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 3966f2d..56d4e75 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -40,6 +40,7 @@ Route::post('/onboarding/platform', [OnboardingController::class, 'createPlatform'])->name('api.onboarding.platform'); Route::post('/onboarding/feed', [OnboardingController::class, 'createFeed'])->name('api.onboarding.feed'); Route::post('/onboarding/channel', [OnboardingController::class, 'createChannel'])->name('api.onboarding.channel'); + Route::post('/onboarding/route', [OnboardingController::class, 'createRoute'])->name('api.onboarding.route'); Route::post('/onboarding/complete', [OnboardingController::class, 'complete'])->name('api.onboarding.complete'); Route::post('/onboarding/skip', [OnboardingController::class, 'skip'])->name('api.onboarding.skip'); Route::post('/onboarding/reset-skip', [OnboardingController::class, 'resetSkip'])->name('api.onboarding.reset-skip'); diff --git a/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php b/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php index dc98eb2..8bb2e60 100644 --- a/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php +++ b/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php @@ -7,6 +7,7 @@ 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 Illuminate\Foundation\Testing\RefreshDatabase; @@ -43,6 +44,7 @@ public function test_status_shows_needs_onboarding_when_no_components_exist() 'has_platform_account' => false, 'has_feed' => false, 'has_channel' => false, + 'has_route' => false, 'onboarding_skipped' => false, ], ]); @@ -63,6 +65,7 @@ public function test_status_shows_feed_step_when_platform_account_exists() 'has_platform_account' => true, 'has_feed' => false, 'has_channel' => false, + 'has_route' => false, ], ]); } @@ -83,6 +86,29 @@ public function test_status_shows_channel_step_when_platform_account_and_feed_ex 'has_platform_account' => true, 'has_feed' => true, 'has_channel' => false, + 'has_route' => false, + ], + ]); + } + + public function test_status_shows_route_step_when_platform_account_feed_and_channel_exist() + { + PlatformAccount::factory()->create(['is_active' => true]); + Feed::factory()->create(['is_active' => true]); + PlatformChannel::factory()->create(['is_active' => true]); + + $response = $this->getJson('/api/v1/onboarding/status'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'needs_onboarding' => true, + 'current_step' => 'route', + 'has_platform_account' => true, + 'has_feed' => true, + 'has_channel' => true, + 'has_route' => false, ], ]); } @@ -92,6 +118,7 @@ public function test_status_shows_no_onboarding_needed_when_all_components_exist PlatformAccount::factory()->create(['is_active' => true]); Feed::factory()->create(['is_active' => true]); PlatformChannel::factory()->create(['is_active' => true]); + Route::factory()->create(['is_active' => true]); $response = $this->getJson('/api/v1/onboarding/status'); @@ -104,6 +131,7 @@ public function test_status_shows_no_onboarding_needed_when_all_components_exist 'has_platform_account' => true, 'has_feed' => true, 'has_channel' => true, + 'has_route' => true, ], ]); } @@ -127,6 +155,7 @@ public function test_status_shows_no_onboarding_needed_when_skipped() 'has_platform_account' => false, 'has_feed' => false, 'has_channel' => false, + 'has_route' => false, 'onboarding_skipped' => true, ], ]); @@ -238,6 +267,47 @@ public function test_create_channel_creates_channel_successfully() ]); } + public function test_create_route_validates_required_fields() + { + $response = $this->postJson('/api/v1/onboarding/route', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['feed_id', 'platform_channel_id']); + } + + public function test_create_route_creates_route_successfully() + { + $feed = Feed::factory()->create(); + $platformChannel = PlatformChannel::factory()->create(); + + $routeData = [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $platformChannel->id, + 'priority' => 75, + 'filters' => ['keyword' => 'test'], + ]; + + $response = $this->postJson('/api/v1/onboarding/route', $routeData); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $platformChannel->id, + 'priority' => 75, + 'is_active' => true, + ] + ]); + + $this->assertDatabaseHas('routes', [ + 'feed_id' => $feed->id, + 'platform_channel_id' => $platformChannel->id, + 'priority' => 75, + 'is_active' => true, + ]); + } + public function test_complete_onboarding_returns_success() { $response = $this->postJson('/api/v1/onboarding/complete'); diff --git a/docker/dev/podman/container-start.sh b/docker/dev/podman/container-start.sh index 175e412..2f95f04 100755 --- a/docker/dev/podman/container-start.sh +++ b/docker/dev/podman/container-start.sh @@ -11,9 +11,27 @@ echo "Installing PHP dependencies..." cd /var/www/html/backend composer install --no-interaction -# Generate application key -echo "Generating application key..." -php artisan key:generate --force +# Ensure APP_KEY is set in backend/.env +ENV_APP_KEY="${APP_KEY}" +if [ -n "$ENV_APP_KEY" ]; then + echo "Using APP_KEY from environment" + sed -i "s|^APP_KEY=.*|APP_KEY=${ENV_APP_KEY}|" /var/www/html/backend/.env || true +fi + +# Generate application key if still missing +CURRENT_APP_KEY=$(grep "^APP_KEY=" /var/www/html/backend/.env | cut -d'=' -f2) +if [ -z "$CURRENT_APP_KEY" ]; then + echo "Generating application key..." + php artisan key:generate --force +fi + +# Verify APP_KEY +APP_KEY=$(grep "^APP_KEY=" /var/www/html/backend/.env | cut -d'=' -f2) +if [ -n "$APP_KEY" ]; then + echo "✅ APP_KEY successfully set." +else + echo "❌ ERROR: APP_KEY not set!" +fi # Wait for database to be ready echo "Waiting for database..." @@ -45,8 +63,11 @@ npm run dev -- --host 0.0.0.0 --port 5173 & cd /var/www/html/backend php artisan serve --host=127.0.0.1 --port=8000 & +# Start Horizon (manages queue workers in dev) +php artisan horizon & + # Start nginx nginx -g "daemon off;" & # Wait for background processes -wait \ No newline at end of file +wait diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx index b5a1525..9557078 100644 --- a/frontend/src/contexts/OnboardingContext.tsx +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -28,17 +28,19 @@ export const OnboardingProvider: React.FC = ({ children const needsOnboarding = onboardingStatus?.needs_onboarding ?? false; const isOnOnboardingPage = location.pathname.startsWith('/onboarding'); - // Redirect logic - only redirect if user explicitly navigates to a protected route + // Redirect logic React.useEffect(() => { if (isLoading) return; - // Only redirect if user doesn't need onboarding but is on onboarding pages + // If user doesn't need onboarding but is on onboarding pages, redirect to dashboard if (!needsOnboarding && isOnOnboardingPage) { navigate('/dashboard', { replace: true }); } - // Don't auto-redirect to onboarding - let user navigate manually or via links - // This prevents the app from being "stuck" in onboarding mode + // If user needs onboarding but is not on onboarding pages, redirect to onboarding + if (needsOnboarding && !isOnOnboardingPage) { + navigate('/onboarding', { replace: true }); + } }, [onboardingStatus, isLoading, needsOnboarding, isOnOnboardingPage, navigate]); const value: OnboardingContextValue = { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 01a004a..8b28dcf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -147,16 +147,19 @@ export interface PlatformInstance { export interface OnboardingStatus { needs_onboarding: boolean; - current_step: 'platform' | 'feed' | 'channel' | 'complete' | null; + current_step: 'platform' | 'feed' | 'channel' | 'route' | 'complete' | null; has_platform_account: boolean; has_feed: boolean; has_channel: boolean; + has_route: boolean; onboarding_skipped: boolean; } export interface OnboardingOptions { languages: Language[]; platform_instances: PlatformInstance[]; + feeds: Feed[]; + platform_channels: PlatformChannel[]; } export interface PlatformAccountRequest { @@ -181,6 +184,25 @@ export interface ChannelRequest { description?: string; } +export interface Route { + feed_id: number; + platform_channel_id: number; + is_active: boolean; + priority: number; + filters: Record; + created_at: string; + updated_at: string; + feed?: Feed; + platform_channel?: PlatformChannel; +} + +export interface RouteRequest { + feed_id: number; + platform_channel_id: number; + priority?: number; + filters?: Record; +} + // API Client class class ApiClient { constructor() { @@ -226,8 +248,8 @@ class ApiClient { // Feeds endpoints async getFeeds(): Promise { - const response = await axios.get>('/feeds'); - return response.data.data; + const response = await axios.get>('/feeds'); + return response.data.data.feeds; } async createFeed(data: Partial): Promise { @@ -286,6 +308,11 @@ class ApiClient { return response.data.data; } + async createRouteForOnboarding(data: RouteRequest): Promise { + const response = await axios.post>('/onboarding/route', data); + return response.data.data; + } + async completeOnboarding(): Promise { await axios.post('/onboarding/complete'); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2e18edd..1dec957 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import './index.css'; import App from './App'; +import { OnboardingProvider } from './contexts/OnboardingContext'; // Create React Query client const queryClient = new QueryClient({ @@ -19,7 +20,9 @@ createRoot(document.getElementById('root')!).render( - + + + , diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 24a130e..9deb1b8 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -49,70 +49,8 @@ const Dashboard: React.FC = () => {

- {/* Article Statistics */} -
-

Article Statistics

-
-
-
-
- -
-
-

Articles Today

-

- {articleStats?.total_today || 0} -

-
-
-
- -
-
-
- -
-
-

Articles This Week

-

- {articleStats?.total_week || 0} -

-
-
-
- -
-
-
- -
-
-

Approved Today

-

- {articleStats?.approved_today || 0} -

-
-
-
- -
-
-
- -
-
-

Approval Rate

-

- {articleStats?.approval_percentage_today?.toFixed(1) || 0}% -

-
-
-
-
-
- {/* System Statistics */} -
+

System Overview

@@ -184,6 +122,68 @@ const Dashboard: React.FC = () => {
+ + {/* Article Statistics */} +
+

Article Statistics

+
+
+
+
+ +
+
+

Articles Today

+

+ {articleStats?.total_today || 0} +

+
+
+
+ +
+
+
+ +
+
+

Articles This Week

+

+ {articleStats?.total_week || 0} +

+
+
+
+ +
+
+
+ +
+
+

Approved Today

+

+ {articleStats?.approved_today || 0} +

+
+
+
+ +
+
+
+ +
+
+

Approval Rate

+

+ {articleStats?.approval_percentage_today?.toFixed(1) || 0}% +

+
+
+
+
+
); }; diff --git a/frontend/src/pages/onboarding/OnboardingWizard.tsx b/frontend/src/pages/onboarding/OnboardingWizard.tsx index b8379bb..20929da 100644 --- a/frontend/src/pages/onboarding/OnboardingWizard.tsx +++ b/frontend/src/pages/onboarding/OnboardingWizard.tsx @@ -5,6 +5,7 @@ import WelcomeStep from './steps/WelcomeStep'; import PlatformStep from './steps/PlatformStep'; import FeedStep from './steps/FeedStep'; import ChannelStep from './steps/ChannelStep'; +import RouteStep from './steps/RouteStep'; import CompleteStep from './steps/CompleteStep'; const OnboardingWizard: React.FC = () => { @@ -15,6 +16,7 @@ const OnboardingWizard: React.FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/pages/onboarding/steps/ChannelStep.tsx b/frontend/src/pages/onboarding/steps/ChannelStep.tsx index b15b244..24dcf9e 100644 --- a/frontend/src/pages/onboarding/steps/ChannelStep.tsx +++ b/frontend/src/pages/onboarding/steps/ChannelStep.tsx @@ -1,10 +1,11 @@ import React, { useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiClient, type ChannelRequest, type Language, type PlatformInstance } from '../../../lib/api'; const ChannelStep: React.FC = () => { const navigate = useNavigate(); + const queryClient = useQueryClient(); const [formData, setFormData] = useState({ name: '', platform_instance_id: 0, @@ -22,7 +23,9 @@ const ChannelStep: React.FC = () => { const createChannelMutation = useMutation({ mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data), onSuccess: () => { - navigate('/onboarding/complete'); + // Invalidate onboarding status cache + queryClient.invalidateQueries({ queryKey: ['onboarding-status'] }); + navigate('/onboarding/route'); }, onError: (error: any) => { if (error.response?.data?.errors) { diff --git a/frontend/src/pages/onboarding/steps/CompleteStep.tsx b/frontend/src/pages/onboarding/steps/CompleteStep.tsx index 9662af5..c5be797 100644 --- a/frontend/src/pages/onboarding/steps/CompleteStep.tsx +++ b/frontend/src/pages/onboarding/steps/CompleteStep.tsx @@ -1,19 +1,25 @@ import React from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '../../../lib/api'; const CompleteStep: React.FC = () => { const navigate = useNavigate(); + const queryClient = useQueryClient(); const completeOnboardingMutation = useMutation({ mutationFn: () => apiClient.completeOnboarding(), onSuccess: () => { + // Invalidate onboarding status cache to ensure proper redirect logic + queryClient.invalidateQueries({ queryKey: ['onboarding-status'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] }); navigate('/dashboard'); }, onError: (error) => { console.error('Failed to complete onboarding:', error); - // Still navigate to dashboard even if completion fails + // Still invalidate cache and navigate to dashboard even if completion fails + queryClient.invalidateQueries({ queryKey: ['onboarding-status'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] }); navigate('/dashboard'); } }); diff --git a/frontend/src/pages/onboarding/steps/FeedStep.tsx b/frontend/src/pages/onboarding/steps/FeedStep.tsx index 4fcb253..79fcd4f 100644 --- a/frontend/src/pages/onboarding/steps/FeedStep.tsx +++ b/frontend/src/pages/onboarding/steps/FeedStep.tsx @@ -136,8 +136,8 @@ const FeedStep: React.FC = () => { handleChange('feed_id', e.target.value ? parseInt(e.target.value) : 0)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + > + + {options?.feeds?.map((feed: Feed) => ( + + ))} + + {errors.feed_id && ( +

{errors.feed_id[0]}

+ )} + + +
+ + + {(!options?.platform_channels || options.platform_channels.length === 0) && ( +

+ No channels available. Please create a channel first. +

+ )} + {errors.platform_channel_id && ( +

{errors.platform_channel_id[0]}

+ )} +
+ +
+ + handleChange('priority', parseInt(e.target.value))} + placeholder="50" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +

+ Higher priority routes are processed first (default: 50) +

+ {errors.priority && ( +

{errors.priority[0]}

+ )} +
+ +
+ + ← Back + + +
+ + + ); +}; + +export default RouteStep; \ No newline at end of file diff --git a/frontend/src/pages/onboarding/steps/WelcomeStep.tsx b/frontend/src/pages/onboarding/steps/WelcomeStep.tsx index 793c1af..9678f55 100644 --- a/frontend/src/pages/onboarding/steps/WelcomeStep.tsx +++ b/frontend/src/pages/onboarding/steps/WelcomeStep.tsx @@ -41,6 +41,10 @@ const WelcomeStep: React.FC = () => {
4
+ Create a route +
+
+
5
You're ready to go!