From 53acdb40b724efa04f9d346ee89356bb4e253b71 Mon Sep 17 00:00:00 2001 From: Daniel Stock Date: Wed, 3 Dec 2025 14:23:03 +0100 Subject: [PATCH] Impment webcron and internal scheduling methods and change settings to a key value store --- app/Console/Commands/CaldavSyncCommand.php | 17 +++-- app/Console/Commands/Kernel.php | 6 -- app/Http/Controllers/InvoiceController.php | 71 ++++++++++++++----- app/Http/Controllers/SettingController.php | 22 ++++-- .../CheckInvoiceDueDatesListener.php | 27 ------- app/Listeners/ScheduleListener.php | 45 ++++++++++++ app/Models/Setting.php | 14 +++- app/Providers/AppServiceProvider.php | 4 +- app/Providers/EventServiceProvider.php | 29 ++++++-- ...025_10_10_075047_create_settings_table.php | 15 ++-- database/seeders/SettingsTableSeeder.php | 10 +-- routes/web.php | 52 +++++++++++++- 12 files changed, 222 insertions(+), 90 deletions(-) delete mode 100644 app/Listeners/CheckInvoiceDueDatesListener.php create mode 100644 app/Listeners/ScheduleListener.php diff --git a/app/Console/Commands/CaldavSyncCommand.php b/app/Console/Commands/CaldavSyncCommand.php index 2e8d6e1..04b1823 100644 --- a/app/Console/Commands/CaldavSyncCommand.php +++ b/app/Console/Commands/CaldavSyncCommand.php @@ -3,20 +3,27 @@ namespace App\Console\Commands; use Illuminate\Console\Command; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Cache; use App\Services\CaldavService; use App\Models\Todo; -use App\Models\TodoType; -use Sabre\VObject\Component\VCalendar; -use Illuminate\Support\Facades\Log; class CaldavSyncCommand extends Command { - protected $signature = 'caldav:sync {--calendar= : optional calendar path}'; + protected $signature = 'caldav:sync'; protected $description = 'Sync CalDAV VTODOs into local todos table'; public function handle(CaldavService $service) { - $this->info('Starting CalDAV sync...'); + // only run every 5 minutes although the task is called every minute + $cacheKey = 'caldav_sync_last_run'; + if (\Illuminate\Support\Facades\Cache::has($cacheKey)) { + return 0; + } + Cache::put($cacheKey, true, 300); + + Log::info('Running CalDAV sync'); + $this->info('Starting CalDAV sync'); $todos = $service->getTodos(); diff --git a/app/Console/Commands/Kernel.php b/app/Console/Commands/Kernel.php index ac665f8..8b6c638 100644 --- a/app/Console/Commands/Kernel.php +++ b/app/Console/Commands/Kernel.php @@ -21,12 +21,6 @@ class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule): void { - // Beispiel: CalDAV Sync jede Stunde - $schedule->command('caldav:sync')->hourly(); - - // Alternativen: - // $schedule->command('caldav:sync')->daily(); - // $schedule->command('caldav:sync --calendar=/calendars/me/default/')->dailyAt('02:00'); } /** diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php index b890689..820d0cd 100644 --- a/app/Http/Controllers/InvoiceController.php +++ b/app/Http/Controllers/InvoiceController.php @@ -7,21 +7,22 @@ use Inertia\Inertia; use App\Models\Invoice; +use App\Models\LineItem; +use App\Models\Setting; +use App\Mail\Reminder; +use App\Support\ApiDataTransformer; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Mail; use Barryvdh\DomPDF\Facade\Pdf; +use tbQuar\Facades\Quar; use horstoeko\zugferdlaravel\Facades\ZugferdLaravel; // use horstoeko\zugferd\codelists\ZugferdInvoiceType; use horstoeko\zugferd\codelists\ZugferdUnitCodes; use horstoeko\zugferd\codelists\ZugferdVatCategoryCodes; use horstoeko\zugferd\codelists\ZugferdVatTypeCodes; use horstoeko\zugferd\codelists\ZugferdDocumentType; -use App\Models\LineItem; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; -use App\Support\ApiDataTransformer; use DateTime; -use tbQuar\Facades\Quar; -use App\Mail\Reminder; -use Illuminate\Support\Facades\Mail; class InvoiceController extends Controller @@ -48,7 +49,7 @@ public function summaryAll() public function summaryThisYear() { - $dt = new DateTime('first day of january this year'); + $dt = new DateTime('first day of january this year'); $invoices = Invoice::select() ->where('invoice_date', '>=', $dt->format('Y-m-d')) ->orderBy('invoice_date', 'asc') @@ -62,7 +63,7 @@ public function summaryThisYear() public function summaryBeforeThisYear() { - $dt = new DateTime('first day of january this year'); + $dt = new DateTime('first day of january this year'); $invoices = Invoice::select() ->where('invoice_date', '<', $dt->format('Y-m-d')) ->orderBy('invoice_date', 'asc') @@ -691,19 +692,53 @@ public function remind(Request $request, int $id) */ protected function generateInvoiceNumber() { - $settings = \App\Models\Setting::firstOrCreate([]); - $lastInvoice = Invoice::orderByRaw("CAST(SUBSTRING(nr, 4) AS UNSIGNED) DESC") - ->first(); + // Key/value Einstellungen + $format = Setting::where('key', 'invoice.number_format')->value('value') ?? 'RE-{number}'; + $start = (int) (Setting::where('key', 'invoice.number_start')->value('value') ?? 1); - if ($lastInvoice) { - $lastNumber = (int) str_replace('RE-', '', $lastInvoice->nr); - $newNumber = $lastNumber + 1; - } else { - $newNumber = $settings->invoice_number_start; + // prefix / suffix aus dem Format ableiten (erwartet genau ein {number}) + $parts = explode('{number}', $format); + $prefix = $parts[0] ?? ''; + $suffix = $parts[1] ?? ''; + + // Query: nur Rechnungen mit Prefix (falls vorhanden) - reduziert Datensatz + $query = \App\Models\Invoice::query(); + if ($prefix !== '') { + $query->where('nr', 'like', $prefix . '%'); + } + // optional: further restrict by suffix if known + if ($suffix !== '') { + $query->where('nr', 'like', '%' . $suffix); } - return str_replace('{number}', $newNumber, $settings->invoice_number_format); + // Hole die Nummern (nur Spalte 'nr') — bei großen Datenmengen ggf. limit/stream verwenden + $numbers = $query->pluck('nr')->toArray(); + + $max = 0; + foreach ($numbers as $nr) { + // Entferne Prefix/Suffix falls vorhanden + if ($prefix !== '' && str_starts_with($nr, $prefix)) { + $nrCore = substr($nr, strlen($prefix)); + } else { + $nrCore = $nr; + } + if ($suffix !== '' && str_ends_with($nrCore, $suffix)) { + $nrCore = substr($nrCore, 0, -strlen($suffix)); + } + + // Extrahiere erste Ziffernfolge (handhabt auch führende Nullen) + if (preg_match('/\d+/', $nrCore, $m)) { + $val = intval($m[0]); + if ($val > $max) $max = $val; + } + } + + $newNumber = $max > 0 ? $max + 1 : $start; + + // Setze neue Nummer ins Format (bei Bedarf Padding hier ergänzen) + return str_replace('{number}', (string)$newNumber, $format); } + /** * Generate a GiroCode for the invoice diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index a92989c..f03d5a8 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -9,19 +9,27 @@ class SettingController extends Controller { public function index() { - return Setting::firstOrCreate([]); + return response()->json(Setting::allKeyValue()); } public function update(Request $request) { - $validatedData = $request->validate([ - 'invoice_number_format' => 'required|string', - 'invoice_number_start' => 'required|integer', + // Batch: {"settings": {"invoice_number_start": 2, "foo": "bar"}} + if ($request->has('settings') && is_array($request->input('settings'))) { + foreach ($request->input('settings') as $k => $v) { + Setting::updateOrCreate(['key' => $k], ['value' => is_scalar($v) ? (string)$v : json_encode($v)]); + } + return response()->json(Setting::allKeyValue()); + } + + // Single: {"key":"invoice_number_start","value":2} + $validated = $request->validate([ + 'key' => 'required|string', + 'value' => 'nullable', ]); - $setting = Setting::firstOrCreate([]); - $setting->update($validatedData); + Setting::updateOrCreate(['key' => $validated['key']], ['value' => isset($validated['value']) && !is_scalar($validated['value']) ? json_encode($validated['value']) : (string)($validated['value'] ?? null)]); - return response()->json($setting, 200); + return response()->json(Setting::allKeyValue()); } } \ No newline at end of file diff --git a/app/Listeners/CheckInvoiceDueDatesListener.php b/app/Listeners/CheckInvoiceDueDatesListener.php deleted file mode 100644 index dbce4c7..0000000 --- a/app/Listeners/CheckInvoiceDueDatesListener.php +++ /dev/null @@ -1,27 +0,0 @@ -toDateString(); - $cacheKey = 'invoice_due_dates_checked_' . $today; - - if (!Cache::has($cacheKey)) { - // Cache a timestamped key, so we only check once a day - Cache::put($cacheKey, true, now()->endOfDay()); - Log::info("Starting daily job to check for due invoices."); - (new CheckInvoiceDueDatesJob())->handle(); - } - } catch (\Exception $e) { - Log::error("Error in CheckInvoiceDueDatesListener: " . $e->getMessage()); - } - } -} \ No newline at end of file diff --git a/app/Listeners/ScheduleListener.php b/app/Listeners/ScheduleListener.php new file mode 100644 index 0000000..328eaff --- /dev/null +++ b/app/Listeners/ScheduleListener.php @@ -0,0 +1,45 @@ +value('value') ?? 'internal'; + if ($method !== 'internal') { + return; + } + + // throttle key: run at most once every 55 seconds + $cacheKey = 'caramel_scheduler_last_run'; + $ttlSeconds = 55; + + if (Cache::has($cacheKey)) { + return; + } + + // mark as run + Cache::put($cacheKey, true, $ttlSeconds); + + try { + Log::info('Triggering scheduler via ScheduleListener'); + Artisan::call('schedule:run'); + } catch (\Throwable $e) { + Log::error('Error running scheduler: ' . $e->getMessage()); + } + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 61b31f9..97b5262 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -9,8 +9,18 @@ class Setting extends Model { use HasFactory; + protected $primaryKey = 'key'; + public $incrementing = false; + protected $keyType = 'string'; + protected $fillable = [ - 'invoice_number_format', - 'invoice_number_start', + 'key', + 'value', ]; + + // Hilfsmethode: alle Einstellungen als key=>value Array + public static function allKeyValue(): array + { + return self::query()->pluck('value', 'key')->toArray(); + } } \ No newline at end of file diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f4fd8a5..1dd8dcf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,8 @@ use Illuminate\Support\ServiceProvider; use Inertia\Inertia; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Schedule; +use App\Jobs\CheckInvoiceDueDatesJob; class AppServiceProvider extends ServiceProvider { @@ -37,4 +39,4 @@ public function boot(): void return ""; }); } -} \ No newline at end of file +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index e7720c1..a60433f 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -3,14 +3,17 @@ namespace App\Providers; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Schedule; +use Illuminate\Support\Facades\Event; +use Illuminate\Routing\Events\RouteMatched; +use App\Models\Setting; +use App\Listeners\ScheduleListener; +use App\Jobs\CheckInvoiceDueDatesJob; class EventServiceProvider extends ServiceProvider { - protected $listen = [ - 'Illuminate\Routing\Events\RouteMatched' => [ - \App\Listeners\CheckInvoiceDueDatesListener::class, - ], - ]; + protected $listen = []; /** * Bootstrap services. @@ -18,5 +21,21 @@ class EventServiceProvider extends ServiceProvider public function boot(): void { parent::boot(); + + $method = Setting::where('key', 'app.schedule_method')->value('value') ?? 'internal'; + if ($method === 'internal') { + Event::listen(RouteMatched::class, [ScheduleListener::class, 'handle']); + } + + // TODO: read where to put these + // it seems to work here, but where is the apropriate place? + // Kernel::schedule did not work + Schedule::command('caldav:sync') + ->everyMinute() + ->withoutOverlapping(); + + Schedule::job(new CheckInvoiceDueDatesJob()) + ->daily() + ->withoutOverlapping(); } } diff --git a/database/migrations/2025_10_10_075047_create_settings_table.php b/database/migrations/2025_10_10_075047_create_settings_table.php index 3cd20e0..0cf992f 100644 --- a/database/migrations/2025_10_10_075047_create_settings_table.php +++ b/database/migrations/2025_10_10_075047_create_settings_table.php @@ -6,24 +6,19 @@ return new class extends Migration { - /** - * Run the migrations. - */ public function up(): void { + Schema::dropIfExists('settings'); + Schema::create('settings', function (Blueprint $table) { - $table->id(); - $table->string('invoice_number_format')->default('RE-{number}'); - $table->integer('invoice_number_start')->default(1); + $table->string('key')->primary(); + $table->text('value')->nullable(); $table->timestamps(); }); } - /** - * Reverse the migrations. - */ public function down(): void { Schema::dropIfExists('settings'); } -}; \ No newline at end of file +}; diff --git a/database/seeders/SettingsTableSeeder.php b/database/seeders/SettingsTableSeeder.php index 8f3784e..7c4a1ef 100644 --- a/database/seeders/SettingsTableSeeder.php +++ b/database/seeders/SettingsTableSeeder.php @@ -7,14 +7,10 @@ class SettingsTableSeeder extends Seeder { - /** - * Run the database seeds. - */ public function run(): void { - Setting::firstOrCreate([], [ - 'invoice_number_format' => 'RE-{number}', - 'invoice_number_start' => 1, - ]); + Setting::updateOrCreate(['key' => 'invoices.number_format'], ['value' => 'RE-{number}']); + Setting::updateOrCreate(['key' => 'invoices.number_start'], ['value' => '1']); + Setting::updateOrCreate(['key' => 'app.schedule_method'], ['value' => '1']); } } \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index acc2d62..342d7be 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,8 +1,11 @@ name('proceduralDocumentation'); + + + /* + |-------------------------------------------------------------------------- + | Web cron route + |-------------------------------------------------------------------------- + | + | Example: GET /webcron?token=SECRET or set header X-WEBCron-Token: SECRET + | Configure secret in .env as WEBCRON_SECRET (optional). If no secret is set, + | the route is open (not recommended in production). + | + */ + Route::get('/webcron', function (Request $request) { + // only allow if scheduling method is webcron + $method = \App\Models\Setting::where('key', 'app.schedule_method')->value('value') ?? 'internal'; + if ($method !== 'webcron') { + return response('Not Found', 404); + } + + $secret = env('WEBCRON_SECRET', null); + + // basic token protection + if ($secret) { + $token = $request->query('token') ?? $request->header('X-WEBCron-Token'); + if (!$token || !hash_equals((string)$secret, (string)$token)) { + return response('Forbidden', 403); + } + } + + // quick throttle to avoid abuse (server-side) + $cacheKey = 'caramel_webcron_last_run'; + if (\Illuminate\Support\Facades\Cache::has($cacheKey)) { + return response('Throttled', 429); + } + Cache::put($cacheKey, true, 55); + + try { + Log::info('Triggering scheduler via /webcron route'); + Artisan::call('schedule:run'); + return response('OK', 200); + } catch (\Throwable $e) { + Log::error('Error running scheduler: ' . $e->getMessage()); + return response('Error', 500); + } + }); });