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
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}% -
-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}% +
+