Minor fixes
This commit is contained in:
parent
387920e82b
commit
2a68895ba9
21 changed files with 650 additions and 123 deletions
62
backend/.env.broken
Normal file
62
backend/.env.broken
Normal file
|
|
@ -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}"
|
||||||
|
|
@ -6,11 +6,13 @@
|
||||||
use App\Http\Resources\FeedResource;
|
use App\Http\Resources\FeedResource;
|
||||||
use App\Http\Resources\PlatformAccountResource;
|
use App\Http\Resources\PlatformAccountResource;
|
||||||
use App\Http\Resources\PlatformChannelResource;
|
use App\Http\Resources\PlatformChannelResource;
|
||||||
|
use App\Http\Resources\RouteResource;
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
use App\Models\Language;
|
use App\Models\Language;
|
||||||
use App\Models\PlatformAccount;
|
use App\Models\PlatformAccount;
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
use App\Models\PlatformInstance;
|
use App\Models\PlatformInstance;
|
||||||
|
use App\Models\Route;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Services\Auth\LemmyAuthService;
|
use App\Services\Auth\LemmyAuthService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
@ -32,12 +34,13 @@ public function status(): JsonResponse
|
||||||
$hasPlatformAccount = PlatformAccount::where('is_active', true)->exists();
|
$hasPlatformAccount = PlatformAccount::where('is_active', true)->exists();
|
||||||
$hasFeed = Feed::where('is_active', true)->exists();
|
$hasFeed = Feed::where('is_active', true)->exists();
|
||||||
$hasChannel = PlatformChannel::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
|
// Check if onboarding was explicitly skipped
|
||||||
$onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true';
|
$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
|
// 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
|
// Determine current step
|
||||||
$currentStep = null;
|
$currentStep = null;
|
||||||
|
|
@ -48,6 +51,8 @@ public function status(): JsonResponse
|
||||||
$currentStep = 'feed';
|
$currentStep = 'feed';
|
||||||
} elseif (!$hasChannel) {
|
} elseif (!$hasChannel) {
|
||||||
$currentStep = 'channel';
|
$currentStep = 'channel';
|
||||||
|
} elseif (!$hasRoute) {
|
||||||
|
$currentStep = 'route';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,6 +62,7 @@ public function status(): JsonResponse
|
||||||
'has_platform_account' => $hasPlatformAccount,
|
'has_platform_account' => $hasPlatformAccount,
|
||||||
'has_feed' => $hasFeed,
|
'has_feed' => $hasFeed,
|
||||||
'has_channel' => $hasChannel,
|
'has_channel' => $hasChannel,
|
||||||
|
'has_route' => $hasRoute,
|
||||||
'onboarding_skipped' => $onboardingSkipped,
|
'onboarding_skipped' => $onboardingSkipped,
|
||||||
], 'Onboarding status retrieved successfully.');
|
], 'Onboarding status retrieved successfully.');
|
||||||
}
|
}
|
||||||
|
|
@ -74,9 +80,21 @@ public function options(): JsonResponse
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(['id', 'platform', 'url', 'name', 'description', 'is_active']);
|
->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([
|
return $this->sendResponse([
|
||||||
'languages' => $languages,
|
'languages' => $languages,
|
||||||
'platform_instances' => $platformInstances,
|
'platform_instances' => $platformInstances,
|
||||||
|
'feeds' => $feeds,
|
||||||
|
'platform_channels' => $platformChannels,
|
||||||
], 'Onboarding options retrieved successfully.');
|
], 'Onboarding options retrieved successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,12 +145,12 @@ public function createPlatform(Request $request): JsonResponse
|
||||||
'instance_url' => $fullInstanceUrl,
|
'instance_url' => $fullInstanceUrl,
|
||||||
'username' => $validated['username'],
|
'username' => $validated['username'],
|
||||||
'password' => $validated['password'],
|
'password' => $validated['password'],
|
||||||
'api_token' => $authResponse['jwt'] ?? null,
|
|
||||||
'settings' => [
|
'settings' => [
|
||||||
'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
|
'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
|
||||||
'description' => $authResponse['person_view']['person']['bio'] ?? null,
|
'description' => $authResponse['person_view']['person']['bio'] ?? null,
|
||||||
'person_id' => $authResponse['person_view']['person']['id'] ?? null,
|
'person_id' => $authResponse['person_view']['person']['id'] ?? null,
|
||||||
'platform_instance_id' => $platformInstance->id,
|
'platform_instance_id' => $platformInstance->id,
|
||||||
|
'api_token' => $authResponse['jwt'] ?? null, // Store JWT in settings for now
|
||||||
],
|
],
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
|
|
@ -144,10 +162,14 @@ public function createPlatform(Request $request): JsonResponse
|
||||||
);
|
);
|
||||||
|
|
||||||
} catch (\App\Exceptions\PlatformAuthException $e) {
|
} 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);
|
return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422);
|
||||||
} catch (\Exception $e) {
|
} 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.';
|
$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
|
// 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
|
* Mark onboarding as complete
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -7,28 +7,37 @@
|
||||||
|
|
||||||
class LemmyRequest
|
class LemmyRequest
|
||||||
{
|
{
|
||||||
private string $instance;
|
private string $host;
|
||||||
|
private string $scheme;
|
||||||
private ?string $token;
|
private ?string $token;
|
||||||
|
|
||||||
public function __construct(string $instance, ?string $token = null)
|
public function __construct(string $instance, ?string $token = null)
|
||||||
{
|
{
|
||||||
// Handle both full URLs and just domain names
|
// Handle both full URLs and just domain names
|
||||||
$this->instance = $this->normalizeInstance($instance);
|
[$this->scheme, $this->host] = $this->parseInstance($instance);
|
||||||
$this->token = $token;
|
$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
|
// Remove protocol if present
|
||||||
$instance = preg_replace('/^https?:\/\//', '', $instance);
|
$host = preg_replace('/^https?:\/\//i', '', $instance);
|
||||||
|
|
||||||
// Remove trailing slash if present
|
// Remove trailing slash if present
|
||||||
$instance = rtrim($instance, '/');
|
$host = rtrim($host ?? '', '/');
|
||||||
|
|
||||||
return $instance;
|
return [$scheme, $host];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -36,7 +45,7 @@ private function normalizeInstance(string $instance): string
|
||||||
*/
|
*/
|
||||||
public function get(string $endpoint, array $params = []): Response
|
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);
|
$request = Http::timeout(30);
|
||||||
|
|
||||||
|
|
@ -52,7 +61,7 @@ public function get(string $endpoint, array $params = []): Response
|
||||||
*/
|
*/
|
||||||
public function post(string $endpoint, array $data = []): 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);
|
$request = Http::timeout(30);
|
||||||
|
|
||||||
|
|
@ -68,4 +77,14 @@ public function withToken(string $token): self
|
||||||
$this->token = $token;
|
$this->token = $token;
|
||||||
return $this;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,29 +18,63 @@ public function __construct(string $instance)
|
||||||
|
|
||||||
public function login(string $username, string $password): ?string
|
public function login(string $username, string $password): ?string
|
||||||
{
|
{
|
||||||
|
// 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'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($schemesToTry as $idx => $scheme) {
|
||||||
try {
|
try {
|
||||||
$request = new LemmyRequest($this->instance);
|
$request = new LemmyRequest($this->instance);
|
||||||
|
// ensure scheme used matches current attempt
|
||||||
|
$request = $request->withScheme($scheme);
|
||||||
|
|
||||||
$response = $request->post('user/login', [
|
$response = $request->post('user/login', [
|
||||||
'username_or_email' => $username,
|
'username_or_email' => $username,
|
||||||
'password' => $password,
|
'password' => $password,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$response->successful()) {
|
if (!$response->successful()) {
|
||||||
|
$responseBody = $response->body();
|
||||||
logger()->error('Lemmy login failed', [
|
logger()->error('Lemmy login failed', [
|
||||||
'status' => $response->status(),
|
'status' => $response->status(),
|
||||||
'body' => $response->body()
|
'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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $response->json();
|
$data = $response->json();
|
||||||
return $data['jwt'] ?? null;
|
return $data['jwt'] ?? null;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logger()->error('Lemmy login exception', ['error' => $e->getMessage()]);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public function getCommunityId(string $communityName, string $token): int
|
public function getCommunityId(string $communityName, string $token): int
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,10 @@ public function authenticate(string $instanceUrl, string $username, string $pass
|
||||||
// Re-throw PlatformAuthExceptions as-is to avoid nesting
|
// Re-throw PlatformAuthExceptions as-is to avoid nesting
|
||||||
throw $e;
|
throw $e;
|
||||||
} catch (Exception $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
|
// For other exceptions, throw a clean PlatformAuthException
|
||||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
|
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,17 @@ public function getSystemStats(): array
|
||||||
')
|
')
|
||||||
->first();
|
->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')
|
$channelStats = DB::table('platform_channels')
|
||||||
->selectRaw('
|
->selectRaw('
|
||||||
COUNT(*) as total_channels,
|
COUNT(*) as total_platform_channels,
|
||||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_channels
|
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_platform_channels
|
||||||
')
|
')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|
@ -102,8 +109,10 @@ public function getSystemStats(): array
|
||||||
return [
|
return [
|
||||||
'total_feeds' => $feedStats->total_feeds,
|
'total_feeds' => $feedStats->total_feeds,
|
||||||
'active_feeds' => $feedStats->active_feeds,
|
'active_feeds' => $feedStats->active_feeds,
|
||||||
'total_channels' => $channelStats->total_channels,
|
'total_platform_accounts' => $accountStats->total_platform_accounts,
|
||||||
'active_channels' => $channelStats->active_channels,
|
'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,
|
'total_routes' => $routeStats->total_routes,
|
||||||
'active_routes' => $routeStats->active_routes,
|
'active_routes' => $routeStats->active_routes,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ class DatabaseSeeder extends Seeder
|
||||||
{
|
{
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->call(SettingsSeeder::class);
|
$this->call([
|
||||||
|
SettingsSeeder::class,
|
||||||
|
LanguageSeeder::class,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
backend/database/seeders/LanguageSeeder.php
Normal file
29
backend/database/seeders/LanguageSeeder.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Language;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class LanguageSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$languages = [
|
||||||
|
// id is auto-increment; we set codes and names
|
||||||
|
['short_code' => '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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
Route::post('/onboarding/platform', [OnboardingController::class, 'createPlatform'])->name('api.onboarding.platform');
|
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/feed', [OnboardingController::class, 'createFeed'])->name('api.onboarding.feed');
|
||||||
Route::post('/onboarding/channel', [OnboardingController::class, 'createChannel'])->name('api.onboarding.channel');
|
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/complete', [OnboardingController::class, 'complete'])->name('api.onboarding.complete');
|
||||||
Route::post('/onboarding/skip', [OnboardingController::class, 'skip'])->name('api.onboarding.skip');
|
Route::post('/onboarding/skip', [OnboardingController::class, 'skip'])->name('api.onboarding.skip');
|
||||||
Route::post('/onboarding/reset-skip', [OnboardingController::class, 'resetSkip'])->name('api.onboarding.reset-skip');
|
Route::post('/onboarding/reset-skip', [OnboardingController::class, 'resetSkip'])->name('api.onboarding.reset-skip');
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
use App\Models\PlatformAccount;
|
use App\Models\PlatformAccount;
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
use App\Models\PlatformInstance;
|
use App\Models\PlatformInstance;
|
||||||
|
use App\Models\Route;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Services\Auth\LemmyAuthService;
|
use App\Services\Auth\LemmyAuthService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
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_platform_account' => false,
|
||||||
'has_feed' => false,
|
'has_feed' => false,
|
||||||
'has_channel' => false,
|
'has_channel' => false,
|
||||||
|
'has_route' => false,
|
||||||
'onboarding_skipped' => false,
|
'onboarding_skipped' => false,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
@ -63,6 +65,7 @@ public function test_status_shows_feed_step_when_platform_account_exists()
|
||||||
'has_platform_account' => true,
|
'has_platform_account' => true,
|
||||||
'has_feed' => false,
|
'has_feed' => false,
|
||||||
'has_channel' => 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_platform_account' => true,
|
||||||
'has_feed' => true,
|
'has_feed' => true,
|
||||||
'has_channel' => false,
|
'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]);
|
PlatformAccount::factory()->create(['is_active' => true]);
|
||||||
Feed::factory()->create(['is_active' => true]);
|
Feed::factory()->create(['is_active' => true]);
|
||||||
PlatformChannel::factory()->create(['is_active' => true]);
|
PlatformChannel::factory()->create(['is_active' => true]);
|
||||||
|
Route::factory()->create(['is_active' => true]);
|
||||||
|
|
||||||
$response = $this->getJson('/api/v1/onboarding/status');
|
$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_platform_account' => true,
|
||||||
'has_feed' => true,
|
'has_feed' => true,
|
||||||
'has_channel' => 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_platform_account' => false,
|
||||||
'has_feed' => false,
|
'has_feed' => false,
|
||||||
'has_channel' => false,
|
'has_channel' => false,
|
||||||
|
'has_route' => false,
|
||||||
'onboarding_skipped' => true,
|
'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()
|
public function test_complete_onboarding_returns_success()
|
||||||
{
|
{
|
||||||
$response = $this->postJson('/api/v1/onboarding/complete');
|
$response = $this->postJson('/api/v1/onboarding/complete');
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,27 @@ echo "Installing PHP dependencies..."
|
||||||
cd /var/www/html/backend
|
cd /var/www/html/backend
|
||||||
composer install --no-interaction
|
composer install --no-interaction
|
||||||
|
|
||||||
# Generate application key
|
# 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..."
|
echo "Generating application key..."
|
||||||
php artisan key:generate --force
|
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
|
# Wait for database to be ready
|
||||||
echo "Waiting for database..."
|
echo "Waiting for database..."
|
||||||
|
|
@ -45,6 +63,9 @@ npm run dev -- --host 0.0.0.0 --port 5173 &
|
||||||
cd /var/www/html/backend
|
cd /var/www/html/backend
|
||||||
php artisan serve --host=127.0.0.1 --port=8000 &
|
php artisan serve --host=127.0.0.1 --port=8000 &
|
||||||
|
|
||||||
|
# Start Horizon (manages queue workers in dev)
|
||||||
|
php artisan horizon &
|
||||||
|
|
||||||
# Start nginx
|
# Start nginx
|
||||||
nginx -g "daemon off;" &
|
nginx -g "daemon off;" &
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,17 +28,19 @@ export const OnboardingProvider: React.FC<OnboardingProviderProps> = ({ children
|
||||||
const needsOnboarding = onboardingStatus?.needs_onboarding ?? false;
|
const needsOnboarding = onboardingStatus?.needs_onboarding ?? false;
|
||||||
const isOnOnboardingPage = location.pathname.startsWith('/onboarding');
|
const isOnOnboardingPage = location.pathname.startsWith('/onboarding');
|
||||||
|
|
||||||
// Redirect logic - only redirect if user explicitly navigates to a protected route
|
// Redirect logic
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isLoading) return;
|
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) {
|
if (!needsOnboarding && isOnOnboardingPage) {
|
||||||
navigate('/dashboard', { replace: true });
|
navigate('/dashboard', { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't auto-redirect to onboarding - let user navigate manually or via links
|
// If user needs onboarding but is not on onboarding pages, redirect to onboarding
|
||||||
// This prevents the app from being "stuck" in onboarding mode
|
if (needsOnboarding && !isOnOnboardingPage) {
|
||||||
|
navigate('/onboarding', { replace: true });
|
||||||
|
}
|
||||||
}, [onboardingStatus, isLoading, needsOnboarding, isOnOnboardingPage, navigate]);
|
}, [onboardingStatus, isLoading, needsOnboarding, isOnOnboardingPage, navigate]);
|
||||||
|
|
||||||
const value: OnboardingContextValue = {
|
const value: OnboardingContextValue = {
|
||||||
|
|
|
||||||
|
|
@ -147,16 +147,19 @@ export interface PlatformInstance {
|
||||||
|
|
||||||
export interface OnboardingStatus {
|
export interface OnboardingStatus {
|
||||||
needs_onboarding: boolean;
|
needs_onboarding: boolean;
|
||||||
current_step: 'platform' | 'feed' | 'channel' | 'complete' | null;
|
current_step: 'platform' | 'feed' | 'channel' | 'route' | 'complete' | null;
|
||||||
has_platform_account: boolean;
|
has_platform_account: boolean;
|
||||||
has_feed: boolean;
|
has_feed: boolean;
|
||||||
has_channel: boolean;
|
has_channel: boolean;
|
||||||
|
has_route: boolean;
|
||||||
onboarding_skipped: boolean;
|
onboarding_skipped: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OnboardingOptions {
|
export interface OnboardingOptions {
|
||||||
languages: Language[];
|
languages: Language[];
|
||||||
platform_instances: PlatformInstance[];
|
platform_instances: PlatformInstance[];
|
||||||
|
feeds: Feed[];
|
||||||
|
platform_channels: PlatformChannel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformAccountRequest {
|
export interface PlatformAccountRequest {
|
||||||
|
|
@ -181,6 +184,25 @@ export interface ChannelRequest {
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Route {
|
||||||
|
feed_id: number;
|
||||||
|
platform_channel_id: number;
|
||||||
|
is_active: boolean;
|
||||||
|
priority: number;
|
||||||
|
filters: Record<string, any>;
|
||||||
|
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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
// API Client class
|
// API Client class
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -226,8 +248,8 @@ class ApiClient {
|
||||||
|
|
||||||
// Feeds endpoints
|
// Feeds endpoints
|
||||||
async getFeeds(): Promise<Feed[]> {
|
async getFeeds(): Promise<Feed[]> {
|
||||||
const response = await axios.get<ApiResponse<Feed[]>>('/feeds');
|
const response = await axios.get<ApiResponse<{feeds: Feed[], pagination: any}>>('/feeds');
|
||||||
return response.data.data;
|
return response.data.data.feeds;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFeed(data: Partial<Feed>): Promise<Feed> {
|
async createFeed(data: Partial<Feed>): Promise<Feed> {
|
||||||
|
|
@ -286,6 +308,11 @@ class ApiClient {
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createRouteForOnboarding(data: RouteRequest): Promise<Route> {
|
||||||
|
const response = await axios.post<ApiResponse<Route>>('/onboarding/route', data);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
async completeOnboarding(): Promise<void> {
|
async completeOnboarding(): Promise<void> {
|
||||||
await axios.post('/onboarding/complete');
|
await axios.post('/onboarding/complete');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { OnboardingProvider } from './contexts/OnboardingContext';
|
||||||
|
|
||||||
// Create React Query client
|
// Create React Query client
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|
@ -19,7 +20,9 @@ createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<OnboardingProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</OnboardingProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
|
|
|
||||||
|
|
@ -49,70 +49,8 @@ const Dashboard: React.FC = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Article Statistics */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Article Statistics</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<FileText className="h-8 w-8 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Articles Today</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{articleStats?.total_today || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Clock className="h-8 w-8 text-yellow-500" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Articles This Week</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{articleStats?.total_week || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<CheckCircle className="h-8 w-8 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Approved Today</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{articleStats?.approved_today || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<TrendingUp className="h-8 w-8 text-purple-500" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Approval Rate</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{articleStats?.approval_percentage_today?.toFixed(1) || 0}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System Statistics */}
|
{/* System Statistics */}
|
||||||
<div>
|
<div className="mb-8">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">System Overview</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">System Overview</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<div className="bg-white p-6 rounded-lg shadow">
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
|
@ -184,6 +122,68 @@ const Dashboard: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Article Statistics */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Article Statistics</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<FileText className="h-8 w-8 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Articles Today</p>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
|
{articleStats?.total_today || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Clock className="h-8 w-8 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Articles This Week</p>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
|
{articleStats?.total_week || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<CheckCircle className="h-8 w-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Approved Today</p>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
|
{articleStats?.approved_today || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<TrendingUp className="h-8 w-8 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Approval Rate</p>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
|
{articleStats?.approval_percentage_today?.toFixed(1) || 0}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import WelcomeStep from './steps/WelcomeStep';
|
||||||
import PlatformStep from './steps/PlatformStep';
|
import PlatformStep from './steps/PlatformStep';
|
||||||
import FeedStep from './steps/FeedStep';
|
import FeedStep from './steps/FeedStep';
|
||||||
import ChannelStep from './steps/ChannelStep';
|
import ChannelStep from './steps/ChannelStep';
|
||||||
|
import RouteStep from './steps/RouteStep';
|
||||||
import CompleteStep from './steps/CompleteStep';
|
import CompleteStep from './steps/CompleteStep';
|
||||||
|
|
||||||
const OnboardingWizard: React.FC = () => {
|
const OnboardingWizard: React.FC = () => {
|
||||||
|
|
@ -15,6 +16,7 @@ const OnboardingWizard: React.FC = () => {
|
||||||
<Route path="platform" element={<PlatformStep />} />
|
<Route path="platform" element={<PlatformStep />} />
|
||||||
<Route path="feed" element={<FeedStep />} />
|
<Route path="feed" element={<FeedStep />} />
|
||||||
<Route path="channel" element={<ChannelStep />} />
|
<Route path="channel" element={<ChannelStep />} />
|
||||||
|
<Route path="route" element={<RouteStep />} />
|
||||||
<Route path="complete" element={<CompleteStep />} />
|
<Route path="complete" element={<CompleteStep />} />
|
||||||
<Route path="*" element={<Navigate to="/onboarding" replace />} />
|
<Route path="*" element={<Navigate to="/onboarding" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
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';
|
import { apiClient, type ChannelRequest, type Language, type PlatformInstance } from '../../../lib/api';
|
||||||
|
|
||||||
const ChannelStep: React.FC = () => {
|
const ChannelStep: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [formData, setFormData] = useState<ChannelRequest>({
|
const [formData, setFormData] = useState<ChannelRequest>({
|
||||||
name: '',
|
name: '',
|
||||||
platform_instance_id: 0,
|
platform_instance_id: 0,
|
||||||
|
|
@ -22,7 +23,9 @@ const ChannelStep: React.FC = () => {
|
||||||
const createChannelMutation = useMutation({
|
const createChannelMutation = useMutation({
|
||||||
mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data),
|
mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
navigate('/onboarding/complete');
|
// Invalidate onboarding status cache
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
||||||
|
navigate('/onboarding/route');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
if (error.response?.data?.errors) {
|
if (error.response?.data?.errors) {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,25 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
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';
|
import { apiClient } from '../../../lib/api';
|
||||||
|
|
||||||
const CompleteStep: React.FC = () => {
|
const CompleteStep: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const completeOnboardingMutation = useMutation({
|
const completeOnboardingMutation = useMutation({
|
||||||
mutationFn: () => apiClient.completeOnboarding(),
|
mutationFn: () => apiClient.completeOnboarding(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
// Invalidate onboarding status cache to ensure proper redirect logic
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Failed to complete onboarding:', 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');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -136,8 +136,8 @@ const FeedStep: React.FC = () => {
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="language_id"
|
id="language_id"
|
||||||
value={formData.language_id}
|
value={formData.language_id || ''}
|
||||||
onChange={(e) => handleChange('language_id', parseInt(e.target.value))}
|
onChange={(e) => handleChange('language_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"
|
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
|
required
|
||||||
>
|
>
|
||||||
|
|
|
||||||
174
frontend/src/pages/onboarding/steps/RouteStep.tsx
Normal file
174
frontend/src/pages/onboarding/steps/RouteStep.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient, type RouteRequest, type Feed, type PlatformChannel } from '../../../lib/api';
|
||||||
|
|
||||||
|
const RouteStep: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formData, setFormData] = useState<RouteRequest>({
|
||||||
|
feed_id: 0,
|
||||||
|
platform_channel_id: 0,
|
||||||
|
priority: 50,
|
||||||
|
filters: {}
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
// Get onboarding options (feeds and channels)
|
||||||
|
const { data: options, isLoading: optionsLoading } = useQuery({
|
||||||
|
queryKey: ['onboarding-options'],
|
||||||
|
queryFn: () => apiClient.getOnboardingOptions()
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRouteMutation = useMutation({
|
||||||
|
mutationFn: (data: RouteRequest) => apiClient.createRouteForOnboarding(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate onboarding status cache to refresh the status
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
|
||||||
|
navigate('/onboarding/complete');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
if (error.response?.data?.errors) {
|
||||||
|
setErrors(error.response.data.errors);
|
||||||
|
} else {
|
||||||
|
setErrors({ general: [error.response?.data?.message || 'An error occurred'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrors({});
|
||||||
|
createRouteMutation.mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (field: keyof RouteRequest, value: string | number | Record<string, any>) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
// Clear field error when user starts typing
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors(prev => ({ ...prev, [field]: [] }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (optionsLoading) {
|
||||||
|
return <div className="text-center">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Create Your First Route</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Connect your feed to a channel by creating a route. This tells FFR which articles to post where.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<div className="flex justify-center mt-6 space-x-2">
|
||||||
|
<div className="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">✓</div>
|
||||||
|
<div className="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">✓</div>
|
||||||
|
<div className="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">✓</div>
|
||||||
|
<div className="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-semibold">4</div>
|
||||||
|
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-xs font-semibold">5</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6 mt-8 text-left">
|
||||||
|
{errors.general && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<p className="text-red-600 text-sm">{errors.general[0]}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="feed_id" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Select Feed
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="feed_id"
|
||||||
|
value={formData.feed_id || ''}
|
||||||
|
onChange={(e) => 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
|
||||||
|
>
|
||||||
|
<option value="">Select a feed</option>
|
||||||
|
{options?.feeds?.map((feed: Feed) => (
|
||||||
|
<option key={feed.id} value={feed.id}>
|
||||||
|
{feed.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.feed_id && (
|
||||||
|
<p className="text-red-600 text-sm mt-1">{errors.feed_id[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="platform_channel_id" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Select Channel
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="platform_channel_id"
|
||||||
|
value={formData.platform_channel_id || ''}
|
||||||
|
onChange={(e) => handleChange('platform_channel_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
|
||||||
|
>
|
||||||
|
<option value="">Select a channel</option>
|
||||||
|
{options?.platform_channels?.map((channel: PlatformChannel) => (
|
||||||
|
<option key={channel.id} value={channel.id}>
|
||||||
|
{channel.display_name || channel.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(!options?.platform_channels || options.platform_channels.length === 0) && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
No channels available. Please create a channel first.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errors.platform_channel_id && (
|
||||||
|
<p className="text-red-600 text-sm mt-1">{errors.platform_channel_id[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Priority (1-100)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="priority"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={formData.priority || 50}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Higher priority routes are processed first (default: 50)
|
||||||
|
</p>
|
||||||
|
{errors.priority && (
|
||||||
|
<p className="text-red-600 text-sm mt-1">{errors.priority[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Link
|
||||||
|
to="/onboarding/channel"
|
||||||
|
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition duration-200"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createRouteMutation.isPending || (!options?.platform_channels || options.platform_channels.length === 0)}
|
||||||
|
className="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createRouteMutation.isPending ? 'Creating...' : 'Continue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RouteStep;
|
||||||
|
|
@ -41,6 +41,10 @@ const WelcomeStep: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
<div className="flex items-center text-sm text-gray-600">
|
||||||
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">4</div>
|
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">4</div>
|
||||||
|
<span>Create a route</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-sm text-gray-600">
|
||||||
|
<div className="w-6 h-6 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center mr-3 text-xs font-semibold">5</div>
|
||||||
<span>You're ready to go!</span>
|
<span>You're ready to go!</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue