diff --git a/app/Console/Commands/EtsyRefreshToken.php b/app/Console/Commands/EtsyRefreshToken.php new file mode 100644 index 0000000..16490b7 --- /dev/null +++ b/app/Console/Commands/EtsyRefreshToken.php @@ -0,0 +1,37 @@ +configured()) { + $this->line('Etsy token refresh skipped: shop is not configured or not connected.'); + + return self::SUCCESS; + } + + try { + $etsy->refreshAccessToken($connection); + $this->line('etsy token: refreshed, expires at '.$connection->refresh()->expires_at->toIso8601String()); + } catch (Throwable $exception) { + $this->warn('etsy token: '.$exception->getMessage()); + + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/EtsySync.php b/app/Console/Commands/EtsySync.php new file mode 100644 index 0000000..baea98c --- /dev/null +++ b/app/Console/Commands/EtsySync.php @@ -0,0 +1,42 @@ +configured()) { + $this->line('Etsy sync skipped: shop is not configured or not connected.'); + + return self::SUCCESS; + } + + try { + $receipts = $etsy->receipts($connection, fresh: true); + $this->line('etsy receipts: refreshed ('.($receipts['count'] ?? 0).' open)'); + } catch (Throwable $exception) { + $this->warn('etsy receipts: '.$exception->getMessage()); + } + + try { + $listings = $etsy->listings($connection, fresh: true); + $this->line('etsy listings: refreshed ('.count($listings['results'] ?? []).' active)'); + } catch (Throwable $exception) { + $this->warn('etsy listings: '.$exception->getMessage()); + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/EtsySettingsController.php b/app/Http/Controllers/EtsySettingsController.php index b3b3f42..8f509f6 100644 --- a/app/Http/Controllers/EtsySettingsController.php +++ b/app/Http/Controllers/EtsySettingsController.php @@ -8,6 +8,7 @@ use App\Models\IntegrationSetting; use App\Services\Etsy; use App\Services\IntegrationSettings; use App\Services\Parcel2Go; +use App\Support\DashboardSchedule; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -24,8 +25,13 @@ class EtsySettingsController extends Controller $parcel2go = $settings->for('parcel2go'); $bambuddy = $settings->for('bambuddy'); $dryer = $settings->for('dryer'); + $scheduleJobs = collect(DashboardSchedule::jobs()) + ->map(fn (array $job, string $key): array => [...$job, 'key' => $key, 'frequency' => DashboardSchedule::frequencyFor($key)]) + ->values()->all(); return view('settings.index', [ + 'scheduleJobs' => $scheduleJobs, + 'scheduleFrequencyOptions' => DashboardSchedule::frequencyOptions(), 'storedCredentials' => $credentials !== null, 'configured' => $etsy->configured(), 'shopConnected' => EtsyConnection::query()->exists(), diff --git a/app/Http/Controllers/ScheduleSettingsController.php b/app/Http/Controllers/ScheduleSettingsController.php new file mode 100644 index 0000000..5d3a8c0 --- /dev/null +++ b/app/Http/Controllers/ScheduleSettingsController.php @@ -0,0 +1,76 @@ +user()->role === 'admin', 403); + + $jobKeys = array_keys(DashboardSchedule::jobs()); + $frequencyKeys = array_keys(DashboardSchedule::frequencyOptions()); + + $validated = $request->validate([ + 'schedules' => ['required', 'array'], + 'schedules.*' => ['required', 'string', Rule::in($frequencyKeys)], + ]); + + $frequencies = collect($validated['schedules']) + ->only($jobKeys) + ->all(); + + DashboardSchedule::persistRuntimeFrequencies($frequencies); + + return redirect() + ->route('settings.index') + ->with('status', 'Schedule settings saved. Laravel will apply them on the next scheduler tick.'); + } + + public function run(Request $request, string $job): RedirectResponse + { + abort_unless($request->user()->role === 'admin', 403); + + $definition = DashboardSchedule::jobs()[$job] ?? null; + + abort_unless($definition !== null && ($definition['manual'] ?? false) === true, 404); + + $command = $definition['command'] ?? null; + + abort_unless(is_string($command) && $command !== '', 404); + + try { + $exitCode = Artisan::call($command); + $output = Str::limit(trim(preg_replace('/\s+/', ' ', Artisan::output()) ?? ''), 220); + } catch (Throwable $exception) { + Log::warning('Manual scheduled job failed.', [ + 'job' => $job, + 'command' => $command, + 'error' => $exception->getMessage(), + ]); + + return redirect() + ->route('settings.index') + ->withErrors(['schedule' => $definition['name'].' failed: '.$exception->getMessage()]); + } + + if ($exitCode !== 0) { + return redirect() + ->route('settings.index') + ->withErrors(['schedule' => $definition['name'].' finished with exit code '.$exitCode.'. '.$output]); + } + + return redirect() + ->route('settings.index') + ->with('status', $definition['name'].' completed.'.($output !== '' ? ' '.$output : '')); + } +} diff --git a/app/Services/Etsy.php b/app/Services/Etsy.php index bc70b2a..fd1a658 100644 --- a/app/Services/Etsy.php +++ b/app/Services/Etsy.php @@ -98,14 +98,14 @@ class Etsy return $total > 0 && $refunded >= $total; } - public function listings(EtsyConnection $connection): array + public function listings(EtsyConnection $connection, bool $fresh = false): array { return $this->cache->remember('etsy.listings.'.$connection->shop_id, 600, fn (): array => $this->request( 'GET', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), ['state' => 'active', 'limit' => 20], - )); + ), $fresh); } /** @@ -392,6 +392,17 @@ class Etsy return $this->credentials()['redirect_uri'] ?: route('etsy.callback'); } + /** + * Public wrapper around the on-demand token refresh logic, for the + * etsy:refresh-token scheduled command to call proactively so the OAuth + * connection stays alive even during quiet periods with no receipts or + * listings activity to trigger a refresh as a side effect. + */ + public function refreshAccessToken(EtsyConnection $connection): string + { + return $this->accessToken($connection); + } + private function accessToken(EtsyConnection $connection): string { if ($connection->expires_at->isFuture()) { diff --git a/app/Support/DashboardSchedule.php b/app/Support/DashboardSchedule.php new file mode 100644 index 0000000..a45cc4f --- /dev/null +++ b/app/Support/DashboardSchedule.php @@ -0,0 +1,175 @@ +> + */ + public static function jobs(): array + { + return [ + 'integrations_refresh' => [ + 'name' => 'Integration cache refresh', + 'feature' => 'Integrations', + 'command' => 'integrations:refresh', + 'description' => 'Refreshes cached Etsy, Parcel2Go, BamBuddy and dryer data so dashboard pages load instantly. Migrated from the previous hard-coded schedule entry — frequency and withoutOverlapping(5) behavior unchanged.', + 'default_frequency' => 'every_30_seconds', + 'overlap_minutes' => 5, + 'manual' => true, + ], + 'alerts_scan' => [ + 'name' => 'Alert scanner', + 'feature' => 'Notifications', + 'command' => 'alerts:scan', + 'description' => 'Publishes notifications for new orders, print progress, low filament and due shipments. Migrated from the previous hard-coded schedule entry — frequency and withoutOverlapping(5) behavior unchanged.', + 'default_frequency' => 'every_30_seconds', + 'overlap_minutes' => 5, + 'manual' => true, + ], + 'etsy_sync' => [ + 'name' => 'Etsy shop sync', + 'feature' => 'Etsy', + 'command' => 'etsy:sync', + 'description' => 'Forces a fresh pull of Etsy receipts and active listings into the same IntegrationCache entries the Etsy page reads, independent of the bundled integrations:refresh job so it can be tuned or disabled on its own.', + 'default_frequency' => 'every_15_minutes', + 'overlap_minutes' => null, + 'manual' => true, + ], + 'etsy_token_refresh' => [ + 'name' => 'Etsy token refresh', + 'feature' => 'Etsy', + 'command' => 'etsy:refresh-token', + 'description' => 'Proactively refreshes the Etsy OAuth access token (tokens last ~1 hour) so the connection stays alive even during quiet periods with no receipts or listings activity to trigger an on-demand refresh.', + 'default_frequency' => 'every_45_minutes', + 'overlap_minutes' => null, + 'manual' => true, + ], + ]; + } + + /** + * @return array + */ + public static function frequencyOptions(): array + { + return [ + 'disabled' => 'Disabled', + 'every_30_seconds' => 'Every 30 seconds', + 'every_5_minutes' => 'Every 5 minutes', + 'every_10_minutes' => 'Every 10 minutes', + 'every_15_minutes' => 'Every 15 minutes', + 'every_30_minutes' => 'Every 30 minutes', + 'every_45_minutes' => 'Every 45 minutes', + 'hourly' => 'Hourly', + 'daily_02' => 'Daily at 02:00', + 'daily_08' => 'Daily at 08:00', + ]; + } + + public static function frequencyFor(string $jobKey): string + { + $job = self::jobs()[$jobKey] ?? null; + $default = $job['default_frequency'] ?? 'disabled'; + + return self::runtimeFrequencyFor($jobKey, $default); + } + + public static function settingKey(string $jobKey): string + { + return 'schedule.'.$jobKey.'.frequency'; + } + + /** + * @param array $frequencies + */ + public static function persistRuntimeFrequencies(array $frequencies): void + { + $payload = collect($frequencies) + ->only(array_keys(self::jobs())) + ->filter(fn (string $frequency): bool => array_key_exists($frequency, self::frequencyOptions())) + ->all(); + + File::ensureDirectoryExists(dirname(self::runtimeSettingsPath())); + File::put(self::runtimeSettingsPath(), json_encode($payload, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); + } + + public static function register(): void + { + foreach (self::jobs() as $jobKey => $job) { + $frequency = self::frequencyFor($jobKey); + + if ($frequency === 'disabled' || ! is_string($job['command'] ?? null)) { + continue; + } + + $event = self::applyFrequency(Schedule::command($job['command']), $frequency); + + $overlap = $job['overlap_minutes'] ?? null; + if ($overlap !== null) { + $event->withoutOverlapping($overlap); + } else { + $event->withoutOverlapping(); + } + } + } + + private static function runtimeFrequencyFor(string $jobKey, string $default): string + { + if (app()->environment('testing') && ! File::exists(self::runtimeSettingsPath())) { + return $default; + } + + try { + if (! File::exists(self::runtimeSettingsPath())) { + return $default; + } + + $settings = json_decode((string) File::get(self::runtimeSettingsPath()), true, 512, JSON_THROW_ON_ERROR); + } catch (Throwable) { + return $default; + } + + $stored = is_array($settings) ? ($settings[$jobKey] ?? $default) : $default; + + return is_string($stored) && array_key_exists($stored, self::frequencyOptions()) ? $stored : $default; + } + + public static function runtimeSettingsPath(): string + { + if (app()->environment('testing')) { + return storage_path('framework/testing/dashboard-schedules.json'); + } + + return storage_path('app/dashboard-schedules.json'); + } + + private static function applyFrequency(Event $event, string $frequency): Event + { + return match ($frequency) { + 'every_30_seconds' => $event->everyThirtySeconds(), + 'every_5_minutes' => $event->everyFiveMinutes(), + 'every_10_minutes' => $event->everyTenMinutes(), + 'every_15_minutes' => $event->everyFifteenMinutes(), + 'every_30_minutes' => $event->everyThirtyMinutes(), + 'every_45_minutes' => $event->cron('*/45 * * * *'), + 'hourly' => $event->hourly(), + 'daily_02' => $event->dailyAt('02:00'), + 'daily_08' => $event->dailyAt('08:00'), + default => $event->hourly(), + }; + } +} diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index e704cc2..7503aaf 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -12,6 +12,7 @@ ['parcel2go', 'Parcel2Go', $integrations['parcel2go']['configured'], $integrations['parcel2go']['stored'], 'Shipping and labels'], ['bambuddy', 'BamBuddy', $integrations['bambuddy']['configured'], $integrations['bambuddy']['stored'], 'Print farm'], ['dryer', 'Filament dryer', $integrations['dryer']['configured'], $integrations['dryer']['stored'], 'Drying controls'], + ['scheduler', 'Scheduler', true, true, 'Job frequencies'], ] as [$section, $name, $isConfigured, $isStored, $description]) {{ $name }} @@ -127,4 +128,44 @@ +
+
+
+

Scheduler

+

Tune how often recurring Artisan jobs run. Laravel applies a changed frequency on its next scheduler tick, no redeploy needed.

+
+
+ +
+ @csrf + @method('PUT') +
+ @foreach($scheduleJobs as $job) +
+
+

{{ $job['name'] }}

+

{{ $job['description'] }}

+

{{ $job['command'] }}

+
+ + @if($job['manual'] ?? false) + + @endif +
+ @endforeach +
+
+
+ + @foreach($scheduleJobs as $job) + @if($job['manual'] ?? false) + + @endif + @endforeach +
+ @endsection diff --git a/routes/console.php b/routes/console.php index 4a993bc..87a7e32 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,9 +1,8 @@ everyThirtySeconds()->withoutOverlapping(5); -Schedule::command('alerts:scan')->everyThirtySeconds()->withoutOverlapping(5); +DashboardSchedule::register(); DevCommands::artisan('schedule:work', 'schedule'); diff --git a/routes/web.php b/routes/web.php index a95693f..359159f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,6 +10,7 @@ use App\Http\Controllers\IntegrationSettingsController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\Parcel2GoConnectionController; use App\Http\Controllers\PrinterController; +use App\Http\Controllers\ScheduleSettingsController; use App\Http\Controllers\ShippingController; use App\Http\Controllers\UserController; use App\Http\Middleware\EnsureAccountActive; @@ -42,6 +43,8 @@ Route::middleware(['auth', EnsureAccountActive::class])->group(function (): void Route::get('/settings', [EtsySettingsController::class, 'index'])->name('settings.index'); Route::put('/settings/etsy', [EtsySettingsController::class, 'update'])->name('settings.etsy.update'); Route::put('/settings/integrations/{provider}', [IntegrationSettingsController::class, 'update'])->name('settings.integrations.update'); + Route::put('/settings/schedules', [ScheduleSettingsController::class, 'update'])->name('settings.schedules.update'); + Route::post('/settings/schedules/{job}/run', [ScheduleSettingsController::class, 'run'])->name('settings.schedules.run'); Route::get('/dryer', [DryerController::class, 'index'])->name('dryer'); Route::get('/dryer/status', [DryerController::class, 'status'])->name('dryer.status'); Route::post('/dryer/control', [DryerController::class, 'control'])->name('dryer.control'); diff --git a/tests/Feature/EtsySyncScheduledJobsTest.php b/tests/Feature/EtsySyncScheduledJobsTest.php new file mode 100644 index 0000000..d0ed391 --- /dev/null +++ b/tests/Feature/EtsySyncScheduledJobsTest.php @@ -0,0 +1,99 @@ + Http::response([], 500)); + + $this->artisan('etsy:sync')->assertExitCode(0); + + Http::assertNothingSent(); + } + + public function test_etsy_sync_refreshes_receipts_and_listings(): void + { + $this->connectShop(); + + Http::fake([ + '*/receipts*' => Http::response(['count' => 1, 'results' => [[ + 'receipt_id' => 1, 'status' => 'open', 'was_paid' => true, 'was_shipped' => false, + ]]]), + '*/listings*' => Http::response(['count' => 1, 'results' => [['listing_id' => 11]]]), + ]); + + $this->artisan('etsy:sync')->assertExitCode(0); + + Http::assertSentCount(2); + } + + public function test_etsy_refresh_token_skips_cleanly_when_shop_is_not_connected(): void + { + Http::fake(fn () => Http::response([], 500)); + + $this->artisan('etsy:refresh-token')->assertExitCode(0); + + Http::assertNothingSent(); + } + + public function test_etsy_refresh_token_proactively_refreshes_an_expired_token(): void + { + $connection = $this->connectShop(expiresAt: now()->subMinute()); + + Http::fake([ + '*/oauth/token' => Http::response([ + 'access_token' => 'new-access-token', + 'refresh_token' => 'new-refresh-token', + 'expires_in' => 3600, + ]), + ]); + + $this->artisan('etsy:refresh-token')->assertExitCode(0); + + Http::assertSentCount(1); + $connection->refresh(); + $this->assertSame('new-access-token', $connection->access_token); + $this->assertTrue($connection->expires_at->isFuture()); + } + + public function test_etsy_refresh_token_does_not_call_etsy_when_token_is_still_fresh(): void + { + $this->connectShop(expiresAt: now()->addMinutes(30)); + + Http::fake(); + + $this->artisan('etsy:refresh-token')->assertExitCode(0); + + Http::assertNothingSent(); + } + + private function connectShop(?Carbon $expiresAt = null): EtsyConnection + { + EtsyAppCredential::create([ + 'id' => 1, + 'keystring' => 'key', + 'shared_secret' => 'secret', + 'shop_name' => 'HickLab3D', + ]); + + return EtsyConnection::create([ + 'shop_id' => 123, + 'shop_name' => 'HickLab3D', + 'access_token' => 'token', + 'refresh_token' => 'refresh', + 'expires_at' => $expiresAt ?? now()->addHour(), + 'scopes' => 'listings_r listings_w transactions_r transactions_w shops_r', + ]); + } +} diff --git a/tests/Feature/ScheduleSettingsTest.php b/tests/Feature/ScheduleSettingsTest.php new file mode 100644 index 0000000..f40b2a5 --- /dev/null +++ b/tests/Feature/ScheduleSettingsTest.php @@ -0,0 +1,82 @@ +get(route('settings.index'))->assertRedirect(route('login')); + + $user = User::factory()->create(); + $this->actingAs($user)->get(route('settings.index'))->assertForbidden(); + $this->actingAs($user)->put(route('settings.schedules.update'), [ + 'schedules' => ['etsy_sync' => 'hourly'], + ])->assertForbidden(); + } + + public function test_admin_can_view_scheduler_section(): void + { + $response = $this + ->actingAs(User::factory()->create(['role' => 'admin'])) + ->get(route('settings.index')); + + $response->assertOk(); + $response->assertSee('Scheduler'); + $response->assertSee('etsy:sync'); + $response->assertSee('etsy:refresh-token'); + $response->assertSee('integrations:refresh'); + $response->assertSee('Run now'); + } + + public function test_admin_can_change_job_frequencies(): void + { + $payload = collect(DashboardSchedule::jobs()) + ->mapWithKeys(fn (array $job, string $key): array => [$key => $job['default_frequency']]) + ->all(); + + $payload['etsy_sync'] = 'hourly'; + $payload['etsy_token_refresh'] = 'disabled'; + + $response = $this + ->actingAs(User::factory()->create(['role' => 'admin'])) + ->put(route('settings.schedules.update'), ['schedules' => $payload]); + + $response->assertRedirect(route('settings.index')); + $this->assertSame('hourly', DashboardSchedule::frequencyFor('etsy_sync')); + $this->assertSame('disabled', DashboardSchedule::frequencyFor('etsy_token_refresh')); + $this->assertFileExists(DashboardSchedule::runtimeSettingsPath()); + } + + public function test_admin_can_manually_run_a_job(): void + { + $response = $this + ->actingAs(User::factory()->create(['role' => 'admin'])) + ->post(route('settings.schedules.run', 'alerts_scan')); + + $response->assertRedirect(route('settings.index')); + $response->assertSessionHas('status'); + } +} diff --git a/tests/Feature/SchedulerTest.php b/tests/Feature/SchedulerTest.php new file mode 100644 index 0000000..466bc24 --- /dev/null +++ b/tests/Feature/SchedulerTest.php @@ -0,0 +1,105 @@ +events()) + ->map(fn ($event): string => $event->getSummaryForDisplay()); + + $this->assertCount(4, $summaries); + $this->assertScheduleContains($summaries, 'integrations:refresh'); + $this->assertScheduleContains($summaries, 'alerts:scan'); + $this->assertScheduleContains($summaries, 'etsy:sync'); + $this->assertScheduleContains($summaries, 'etsy:refresh-token'); + } + + public function test_existing_jobs_keep_their_thirty_second_cadence_and_overlap_guard(): void + { + $events = collect(app(Schedule::class)->events()); + + $refresh = $events->first(fn ($event): bool => str_contains((string) $event->command, 'integrations:refresh')); + $alerts = $events->first(fn ($event): bool => str_contains((string) $event->command, 'alerts:scan')); + + $this->assertNotNull($refresh); + $this->assertNotNull($alerts); + $this->assertSame('* * * * *', $refresh->expression); + $this->assertSame(30, $refresh->repeatSeconds); + $this->assertSame('* * * * *', $alerts->expression); + $this->assertSame(30, $alerts->repeatSeconds); + $this->assertTrue($refresh->withoutOverlapping); + $this->assertSame(5, $refresh->expiresAt); + $this->assertTrue($alerts->withoutOverlapping); + $this->assertSame(5, $alerts->expiresAt); + } + + public function test_etsy_jobs_use_their_default_frequencies(): void + { + $events = collect(app(Schedule::class)->events()); + + $sync = $events->first(fn ($event): bool => str_contains((string) $event->command, 'etsy:sync')); + $tokenRefresh = $events->first(fn ($event): bool => str_contains((string) $event->command, 'etsy:refresh-token')); + + $this->assertNotNull($sync); + $this->assertNotNull($tokenRefresh); + $this->assertSame('*/15 * * * *', $sync->expression); + $this->assertSame('*/45 * * * *', $tokenRefresh->expression); + } + + public function test_changing_a_stored_frequency_changes_what_gets_scheduled(): void + { + DashboardSchedule::persistRuntimeFrequencies([ + 'etsy_sync' => 'hourly', + 'etsy_token_refresh' => 'disabled', + ]); + + // The schedule singleton is built once at boot (before this test writes + // the settings file), so force a fresh Schedule and re-run the registry + // against it to see the just-persisted frequencies take effect, the way + // the next real `schedule:run` boot cycle would. + app()->forgetInstance(Schedule::class); + \Illuminate\Support\Facades\Schedule::clearResolvedInstance(Schedule::class); + DashboardSchedule::register(); + + $events = collect(app(Schedule::class)->events()); + + $sync = $events->first(fn ($event): bool => str_contains((string) $event->command, 'etsy:sync')); + $tokenRefresh = $events->first(fn ($event): bool => str_contains((string) $event->command, 'etsy:refresh-token')); + + $this->assertNotNull($sync); + $this->assertSame('0 * * * *', $sync->expression); + $this->assertNull($tokenRefresh); + } + + private function assertScheduleContains($summaries, string $expected): void + { + $this->assertTrue( + $summaries->contains(fn (string $summary): bool => str_contains($summary, $expected)), + "Failed asserting that the scheduler contains [{$expected}]." + ); + } +}