Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 108 additions & 2 deletions app/Console/Commands/api/GermanyCentral.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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)');
Expand Down Expand Up @@ -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);
Expand All @@ -71,6 +76,8 @@ public function handle(): int
$updated = 0;
$skipped = 0;
$failed = 0;
$deleted = 0;
$wouldDelete = 0;
$issues = [];
$withTags = 0;
$withLeadingTeacher = 0;
Expand Down Expand Up @@ -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'],
[
Expand All @@ -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],
Expand All @@ -164,6 +187,86 @@ public function handle(): int
return ($skipped > 0 || $failed > 0) ? self::FAILURE : self::SUCCESS;
}

/**
* @param array<int, mixed> $items
* @return array<int, string>
*/
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<int, string> $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 = [];
Expand Down Expand Up @@ -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) {
Expand All @@ -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)) {
Expand Down
9 changes: 3 additions & 6 deletions routes/console.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,9 @@

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).
// 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);

Expand Down
Loading