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;
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();
-6
View File
@@ -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');
}
/**
+50 -15
View File
@@ -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
@@ -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;
// 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);
}
// 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 {
$newNumber = $settings->invoice_number_start;
$nrCore = $nr;
}
if ($suffix !== '' && str_ends_with($nrCore, $suffix)) {
$nrCore = substr($nrCore, 0, -strlen($suffix));
}
return str_replace('{number}', $newNumber, $settings->invoice_number_format);
// 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
+15 -7
View File
@@ -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());
}
}
@@ -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;
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();
}
}
+2
View File
@@ -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
{
+24 -5
View File
@@ -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();
}
}
@@ -6,22 +6,17 @@
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');
+3 -7
View File
@@ -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']);
}
}
+50 -2
View File
@@ -1,8 +1,11 @@
<?php
use Illuminate\Support\Facades\Route;
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\CustomerController;
use App\Http\Controllers\ProductController;
@@ -61,6 +64,51 @@
Route::get('proceduralDocumentation', function () {
return Inertia::render('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);
}
});
});