From 135be164100524ce1a8883ab376544191e808823 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Thu, 3 Sep 2026 10:32:58 +0100 Subject: [PATCH 1/2] Disable legacy German city feed schedule after Datenfreunde confirmation. Regional hubs are retired; sync only from the central events.codeweek.de export. Co-authored-by: Cursor --- routes/console.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/routes/console.php b/routes/console.php index bb36783cf..ff7fb5ec2 100644 --- a/routes/console.php +++ b/routes/console.php @@ -22,12 +22,8 @@ Schedule::command('rss:meetandcode')->hourlyAt(5); -// Keep legacy per-city German feeds (no-op while they return [] / 404). -Schedule::command('api:germany')->hourlyAt(10); - -// New central codeweek.de export (single URL). Uses source_ref "codeweek-de:{uid}" -// so it does not overwrite legacy "berlin:{uid}" / "hamburg:{uid}" events. -Schedule::command('api:germany-central --import')->hourlyAt(15); +// Central codeweek.de export only (legacy per-city feeds retired by Datenfreunde). +Schedule::command('api:germany-central --import')->hourlyAt(10); Schedule::command('magic:key')->hourlyAt(13); From bcde8e2d42b8694abda1fa8e22b4c5c790288c2e Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Fri, 2 Oct 2026 12:14:00 +0100 Subject: [PATCH 2/2] Soft-delete codeweek-de events removed from the central export. Reconcile only source_ref codeweek-de:* with safeguards for partial or empty feeds; restore events if they reappear. Co-authored-by: Cursor --- app/Console/Commands/api/GermanyCentral.php | 110 +++++++++++++++++++- routes/console.php | 1 + 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/api/GermanyCentral.php b/app/Console/Commands/api/GermanyCentral.php index fc5c1bce4..da5fadee5 100644 --- a/app/Console/Commands/api/GermanyCentral.php +++ b/app/Console/Commands/api/GermanyCentral.php @@ -17,7 +17,8 @@ class GermanyCentral extends Command protected $signature = 'api:germany-central {--url=https://events.codeweek.de/api/v1/export/ : Central DE export URL} {--limit=0 : Limit number of items (0 = all)} - {--import : Persist events (default is dry-run only)}'; + {--import : Persist events (default is dry-run only)} + {--no-delete : Skip soft-deleting events missing from the export}'; protected $description = 'Validate (and optionally import) the central codeweek.de event export'; @@ -28,6 +29,7 @@ public function handle(): int $url = (string) $this->option('url'); $limit = (int) $this->option('limit'); $doImport = (bool) $this->option('import'); + $skipDelete = (bool) $this->option('no-delete'); $this->info("Fetching: {$url}"); $this->info($doImport ? 'Mode: IMPORT (writes to DB)' : 'Mode: DRY-RUN (no DB writes)'); @@ -61,6 +63,9 @@ public function handle(): int return self::FAILURE; } + // Collect feed uids from the full payload before any --limit slice. + $seenSourceRefs = $this->collectSeenSourceRefs($json); + $items = $json; if ($limit > 0) { $items = array_slice($items, 0, $limit); @@ -71,6 +76,8 @@ public function handle(): int $updated = 0; $skipped = 0; $failed = 0; + $deleted = 0; + $wouldDelete = 0; $issues = []; $withTags = 0; $withLeadingTeacher = 0; @@ -134,6 +141,21 @@ public function handle(): int $bar->finish(); $this->newLine(2); + // Soft-delete previously imported DE events that disappeared from the export. + // Skip when --limit is set (partial feed) or feed is empty (likely API failure). + $deleteStats = $this->reconcileMissingEvents( + $seenSourceRefs, + $doImport, + $skipDelete, + $limit, + count($json) + ); + $deleted = $deleteStats['deleted']; + $wouldDelete = $deleteStats['would_delete']; + if ($deleteStats['skipped_reason'] !== null) { + $this->warn('Deletion sync skipped: '.$deleteStats['skipped_reason']); + } + $this->table( ['Metric', 'Value'], [ @@ -142,6 +164,7 @@ public function handle(): int ['Valid', $ok], ['Created', $doImport ? $created : 'n/a'], ['Updated', $doImport ? $updated : 'n/a'], + ['Soft-deleted (missing from feed)', $doImport ? $deleted : $wouldDelete.' (would delete)'], ['Skipped (validation)', $skipped], ['Failed (save)', $doImport ? $failed : 'n/a'], ['With tags', $withTags], @@ -164,6 +187,86 @@ public function handle(): int return ($skipped > 0 || $failed > 0) ? self::FAILURE : self::SUCCESS; } + /** + * @param array $items + * @return array + */ + private function collectSeenSourceRefs(array $items): array + { + $refs = []; + foreach ($items as $item) { + if (! is_array($item) || ! isset($item['uid']) || ! is_numeric($item['uid'])) { + continue; + } + $refs[] = self::FEED_KEY.':'.(int) $item['uid']; + } + + return array_values(array_unique($refs)); + } + + /** + * Soft-delete codeweek-de events that are no longer present in the export. + * + * @param array $seenSourceRefs + * @return array{deleted: int, would_delete: int, skipped_reason: ?string} + */ + private function reconcileMissingEvents( + array $seenSourceRefs, + bool $doImport, + bool $skipDelete, + int $limit, + int $fullFeedCount + ): array { + $result = ['deleted' => 0, 'would_delete' => 0, 'skipped_reason' => null]; + + if ($skipDelete) { + $result['skipped_reason'] = '--no-delete flag set'; + + return $result; + } + + if ($limit > 0) { + $result['skipped_reason'] = '--limit is set (partial feed would incorrectly delete other events)'; + + return $result; + } + + if ($fullFeedCount === 0 || $seenSourceRefs === []) { + $result['skipped_reason'] = 'export was empty — refusing to delete all imported German events'; + + return $result; + } + + $query = Event::query() + ->where('source_ref', 'like', self::FEED_KEY.':%') + ->whereNotIn('source_ref', $seenSourceRefs); + + $result['would_delete'] = (int) $query->count(); + + if (! $doImport) { + return $result; + } + + if ($result['would_delete'] === 0) { + return $result; + } + + // Soft-delete in chunks to avoid huge IN lists / long locks. + $result['deleted'] = 0; + Event::query() + ->where('source_ref', 'like', self::FEED_KEY.':%') + ->whereNotIn('source_ref', $seenSourceRefs) + ->orderBy('id') + ->chunkById(200, function ($events) use (&$result) { + foreach ($events as $event) { + $event->delete(); + $result['deleted']++; + } + }); + + return $result; + } + private function validateItem(array $item): array { $rowIssues = []; @@ -302,7 +405,7 @@ private function upsertEvent(array $item, int $fallbackCreatorId): string $attrs = $this->mapAttrs($item, $user->id ?: $fallbackCreatorId); $attrs['creator_id'] = $user->id; - $event = Event::where('source_ref', $sourceRef)->first(); + $event = Event::withTrashed()->where('source_ref', $sourceRef)->first(); $created = false; if (! $event) { @@ -313,6 +416,9 @@ private function upsertEvent(array $item, int $fallbackCreatorId): string $event->updated = now(); $event->save(); } else { + if ($event->trashed()) { + $event->restore(); + } $attrs['updated'] = now(); // Keep existing picture if incoming is empty if (empty($attrs['picture']) && ! empty($event->picture)) { diff --git a/routes/console.php b/routes/console.php index ff7fb5ec2..b845ee451 100644 --- a/routes/console.php +++ b/routes/console.php @@ -23,6 +23,7 @@ Schedule::command('rss:meetandcode')->hourlyAt(5); // Central codeweek.de export only (legacy per-city feeds retired by Datenfreunde). +// Soft-deletes codeweek-de:{uid} events that disappear from the export. Schedule::command('api:germany-central --import')->hourlyAt(10); Schedule::command('magic:key')->hourlyAt(13);