Impment webcron and internal scheduling methods and change settings to a key value store

This commit is contained in:
2025-12-03 14:23:03 +01:00
parent e37a14993d
commit 53acdb40b7
12 changed files with 222 additions and 90 deletions
+12 -5
View File
@@ -3,20 +3,27 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
use App\Services\CaldavService; use App\Services\CaldavService;
use App\Models\Todo; use App\Models\Todo;
use App\Models\TodoType;
use Sabre\VObject\Component\VCalendar;
use Illuminate\Support\Facades\Log;
class CaldavSyncCommand extends Command 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'; protected $description = 'Sync CalDAV VTODOs into local todos table';
public function handle(CaldavService $service) 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(); $todos = $service->getTodos();
-6
View File
@@ -21,12 +21,6 @@ class Kernel extends ConsoleKernel
*/ */
protected function schedule(Schedule $schedule): void 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');
} }
/** /**
+53 -18
View File
@@ -7,21 +7,22 @@
use Inertia\Inertia; use Inertia\Inertia;
use App\Models\Invoice; 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 Barryvdh\DomPDF\Facade\Pdf;
use tbQuar\Facades\Quar;
use horstoeko\zugferdlaravel\Facades\ZugferdLaravel; use horstoeko\zugferdlaravel\Facades\ZugferdLaravel;
// use horstoeko\zugferd\codelists\ZugferdInvoiceType; // use horstoeko\zugferd\codelists\ZugferdInvoiceType;
use horstoeko\zugferd\codelists\ZugferdUnitCodes; use horstoeko\zugferd\codelists\ZugferdUnitCodes;
use horstoeko\zugferd\codelists\ZugferdVatCategoryCodes; use horstoeko\zugferd\codelists\ZugferdVatCategoryCodes;
use horstoeko\zugferd\codelists\ZugferdVatTypeCodes; use horstoeko\zugferd\codelists\ZugferdVatTypeCodes;
use horstoeko\zugferd\codelists\ZugferdDocumentType; 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 DateTime;
use tbQuar\Facades\Quar;
use App\Mail\Reminder;
use Illuminate\Support\Facades\Mail;
class InvoiceController extends Controller class InvoiceController extends Controller
@@ -48,7 +49,7 @@ public function summaryAll()
public function summaryThisYear() public function summaryThisYear()
{ {
$dt = new DateTime('first day of january this year'); $dt = new DateTime('first day of january this year');
$invoices = Invoice::select() $invoices = Invoice::select()
->where('invoice_date', '>=', $dt->format('Y-m-d')) ->where('invoice_date', '>=', $dt->format('Y-m-d'))
->orderBy('invoice_date', 'asc') ->orderBy('invoice_date', 'asc')
@@ -62,7 +63,7 @@ public function summaryThisYear()
public function summaryBeforeThisYear() public function summaryBeforeThisYear()
{ {
$dt = new DateTime('first day of january this year'); $dt = new DateTime('first day of january this year');
$invoices = Invoice::select() $invoices = Invoice::select()
->where('invoice_date', '<', $dt->format('Y-m-d')) ->where('invoice_date', '<', $dt->format('Y-m-d'))
->orderBy('invoice_date', 'asc') ->orderBy('invoice_date', 'asc')
@@ -691,19 +692,53 @@ public function remind(Request $request, int $id)
*/ */
protected function generateInvoiceNumber() protected function generateInvoiceNumber()
{ {
$settings = \App\Models\Setting::firstOrCreate([]); // Key/value Einstellungen
$lastInvoice = Invoice::orderByRaw("CAST(SUBSTRING(nr, 4) AS UNSIGNED) DESC") $format = Setting::where('key', 'invoice.number_format')->value('value') ?? 'RE-{number}';
->first(); $start = (int) (Setting::where('key', 'invoice.number_start')->value('value') ?? 1);
if ($lastInvoice) { // prefix / suffix aus dem Format ableiten (erwartet genau ein {number})
$lastNumber = (int) str_replace('RE-', '', $lastInvoice->nr); $parts = explode('{number}', $format);
$newNumber = $lastNumber + 1; $prefix = $parts[0] ?? '';
} else { $suffix = $parts[1] ?? '';
$newNumber = $settings->invoice_number_start;
// 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 * Generate a GiroCode for the invoice
+15 -7
View File
@@ -9,19 +9,27 @@ class SettingController extends Controller
{ {
public function index() public function index()
{ {
return Setting::firstOrCreate([]); return response()->json(Setting::allKeyValue());
} }
public function update(Request $request) public function update(Request $request)
{ {
$validatedData = $request->validate([ // Batch: {"settings": {"invoice_number_start": 2, "foo": "bar"}}
'invoice_number_format' => 'required|string', if ($request->has('settings') && is_array($request->input('settings'))) {
'invoice_number_start' => 'required|integer', 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::updateOrCreate(['key' => $validated['key']], ['value' => isset($validated['value']) && !is_scalar($validated['value']) ? json_encode($validated['value']) : (string)($validated['value'] ?? null)]);
$setting->update($validatedData);
return response()->json($setting, 200); return response()->json(Setting::allKeyValue());
} }
} }
@@ -1,27 +0,0 @@
<?php
namespace App\Listeners;
use Illuminate\Support\Facades\Cache;
use App\Jobs\CheckInvoiceDueDatesJob;
use Illuminate\Support\Facades\Log;
class CheckInvoiceDueDatesListener
{
public function handle($event)
{
try {
$today = now()->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());
}
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Listeners;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use App\Models\Setting;
class ScheduleListener
{
/**
* Handle the event.
*
* This listener is attached to RouteMatched when app.schedule_method == 'internal'.
* It will trigger the scheduler at most once per minute (throttle via cache).
*/
public function handle($event)
{
// only run when internal scheduling is enabled (defensive)
$method = Setting::where('key', 'app.schedule_method')->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());
}
}
}
+12 -2
View File
@@ -9,8 +9,18 @@ class Setting extends Model
{ {
use HasFactory; use HasFactory;
protected $primaryKey = 'key';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [ protected $fillable = [
'invoice_number_format', 'key',
'invoice_number_start', 'value',
]; ];
// Hilfsmethode: alle Einstellungen als key=>value Array
public static function allKeyValue(): array
{
return self::query()->pluck('value', 'key')->toArray();
}
} }
+3 -1
View File
@@ -5,6 +5,8 @@
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Inertia\Inertia; use Inertia\Inertia;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Schedule;
use App\Jobs\CheckInvoiceDueDatesJob;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -37,4 +39,4 @@ public function boot(): void
return "<?php echo str_replace('.', ',', $expression); ?>"; return "<?php echo str_replace('.', ',', $expression); ?>";
}); });
} }
} }
+24 -5
View File
@@ -3,14 +3,17 @@
namespace App\Providers; namespace App\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; 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 class EventServiceProvider extends ServiceProvider
{ {
protected $listen = [ protected $listen = [];
'Illuminate\Routing\Events\RouteMatched' => [
\App\Listeners\CheckInvoiceDueDatesListener::class,
],
];
/** /**
* Bootstrap services. * Bootstrap services.
@@ -18,5 +21,21 @@ class EventServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
parent::boot(); 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();
} }
} }
@@ -6,24 +6,19 @@
return new class extends Migration return new class extends Migration
{ {
/**
* Run the migrations.
*/
public function up(): void public function up(): void
{ {
Schema::dropIfExists('settings');
Schema::create('settings', function (Blueprint $table) { Schema::create('settings', function (Blueprint $table) {
$table->id(); $table->string('key')->primary();
$table->string('invoice_number_format')->default('RE-{number}'); $table->text('value')->nullable();
$table->integer('invoice_number_start')->default(1);
$table->timestamps(); $table->timestamps();
}); });
} }
/**
* Reverse the migrations.
*/
public function down(): void public function down(): void
{ {
Schema::dropIfExists('settings'); Schema::dropIfExists('settings');
} }
}; };
+3 -7
View File
@@ -7,14 +7,10 @@
class SettingsTableSeeder extends Seeder class SettingsTableSeeder extends Seeder
{ {
/**
* Run the database seeds.
*/
public function run(): void public function run(): void
{ {
Setting::firstOrCreate([], [ Setting::updateOrCreate(['key' => 'invoices.number_format'], ['value' => 'RE-{number}']);
'invoice_number_format' => 'RE-{number}', Setting::updateOrCreate(['key' => 'invoices.number_start'], ['value' => '1']);
'invoice_number_start' => 1, Setting::updateOrCreate(['key' => 'app.schedule_method'], ['value' => '1']);
]);
} }
} }
+50 -2
View File
@@ -1,8 +1,11 @@
<?php <?php
use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
use App\Http\Controllers\TodoController; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
use App\Http\Controllers\InvoiceController; use App\Http\Controllers\InvoiceController;
use App\Http\Controllers\CustomerController; use App\Http\Controllers\CustomerController;
use App\Http\Controllers\ProductController; use App\Http\Controllers\ProductController;
@@ -61,6 +64,51 @@
Route::get('proceduralDocumentation', function () { Route::get('proceduralDocumentation', function () {
return Inertia::render('ProceduralDocumentation'); return Inertia::render('ProceduralDocumentation');
})->name('proceduralDocumentation'); })->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);
}
});
}); });