diff --git a/app/Console/Commands/api/GermanTraits.php b/app/Console/Commands/api/GermanTraits.php index 573001209..6e4693b5d 100644 --- a/app/Console/Commands/api/GermanTraits.php +++ b/app/Console/Commands/api/GermanTraits.php @@ -66,6 +66,8 @@ private function createRSSItem($json, $city): void 'themes' => trim(implode(',', \Illuminate\Support\Arr::pluck($item['themes'] ?? [], 'identifier'))), 'audience' => trim(implode(',', \Illuminate\Support\Arr::pluck($item['audience'] ?? [], 'identifier'))), + 'leading_teacher_tag' => trim((string) ($item['leading_teacher_tag'] ?? '')) ?: null, + 'last_updated_at' => now(), ]; @@ -167,6 +169,9 @@ private function createGermanEvent(string $city): void 'language' => 'de', 'codeweek_for_all_participation_code' => "cw-$city", 'mass_added_for' => 'API codeweek_de', + 'leading_teacher_tag' => ! empty(trim((string) ($this->leading_teacher_tag ?? ''))) + ? trim((string) $this->leading_teacher_tag) + : null, // minimal linkage 'source_ref' => $sourceRef, 'source_synced_at'=> now(), @@ -214,6 +219,11 @@ private function createGermanEvent(string $city): void $dirty['location'] = (string)$this->location; } + $leadingTeacherTag = trim((string) ($this->leading_teacher_tag ?? '')); + if ($leadingTeacherTag !== '' && $event->leading_teacher_tag !== $leadingTeacherTag) { + $dirty['leading_teacher_tag'] = $leadingTeacherTag; + } + if (!empty($dirty)) { $dirty['updated'] = now(); $event->fill($dirty); diff --git a/app/Console/Commands/api/GermanyCentral.php b/app/Console/Commands/api/GermanyCentral.php new file mode 100644 index 000000000..fc5c1bce4 --- /dev/null +++ b/app/Console/Commands/api/GermanyCentral.php @@ -0,0 +1,367 @@ +option('url'); + $limit = (int) $this->option('limit'); + $doImport = (bool) $this->option('import'); + + $this->info("Fetching: {$url}"); + $this->info($doImport ? 'Mode: IMPORT (writes to DB)' : 'Mode: DRY-RUN (no DB writes)'); + + try { + $response = Http::timeout(90) + ->acceptJson() + ->withHeaders(['User-Agent' => 'codeweek-eu-germany-central/1.0']) + ->get($url); + } catch (\Throwable $e) { + $this->error('Fetch failed: '.$e->getMessage()); + + return self::FAILURE; + } + + if (! $response->successful()) { + $this->error('HTTP '.$response->status()); + + return self::FAILURE; + } + + $json = $response->json(); + if (is_array($json) && isset($json['events']) && is_array($json['events']) && ! array_is_list($json)) { + $this->error('Top-level is {"events":[...]} — importer expects a JSON array.'); + + return self::FAILURE; + } + if (! is_array($json) || ! array_is_list($json)) { + $this->error('Unexpected JSON shape (expected a JSON array of events).'); + + return self::FAILURE; + } + + $items = $json; + if ($limit > 0) { + $items = array_slice($items, 0, $limit); + } + + $ok = 0; + $created = 0; + $updated = 0; + $skipped = 0; + $failed = 0; + $issues = []; + $withTags = 0; + $withLeadingTeacher = 0; + + $technicalUser = null; + if ($doImport) { + $technicalUser = ImporterHelper::getTechnicalUser('codeweek-de-technical'); + } + + $bar = $this->output->createProgressBar(count($items)); + $bar->start(); + + foreach ($items as $index => $item) { + $bar->advance(); + + if (! is_array($item)) { + $skipped++; + $issues[] = "Item {$index}: not an object"; + continue; + } + + $rowIssues = $this->validateItem($item); + if ($rowIssues !== []) { + $skipped++; + $issues[] = 'uid='.($item['uid'] ?? '?').' — '.implode('; ', $rowIssues); + continue; + } + + $ok++; + if ($ok === 1 && ! $doImport) { + $this->newLine(); + $this->line('Sample mapped attrs:'); + $this->line(json_encode($this->mapAttrs($item, $technicalUser?->id), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + } + + $tags = $item['tags'] ?? []; + if (is_array($tags) && count($tags) > 0) { + $withTags++; + } + if (trim((string) ($item['leading_teacher_tag'] ?? '')) !== '') { + $withLeadingTeacher++; + } + + if (! $doImport) { + continue; + } + + try { + $result = $this->upsertEvent($item, $technicalUser->id); + if ($result === 'created') { + $created++; + } else { + $updated++; + } + } catch (\Throwable $e) { + $failed++; + $issues[] = 'uid='.($item['uid'] ?? '?').' — save failed: '.$e->getMessage(); + } + } + + $bar->finish(); + $this->newLine(2); + + $this->table( + ['Metric', 'Value'], + [ + ['Mode', $doImport ? 'import' : 'dry-run'], + ['Items inspected', count($items)], + ['Valid', $ok], + ['Created', $doImport ? $created : 'n/a'], + ['Updated', $doImport ? $updated : 'n/a'], + ['Skipped (validation)', $skipped], + ['Failed (save)', $doImport ? $failed : 'n/a'], + ['With tags', $withTags], + ['With leading_teacher_tag', $withLeadingTeacher], + ] + ); + + if ($issues !== []) { + $this->warn('Issues (showing up to 25):'); + foreach (array_slice($issues, 0, 25) as $issue) { + $this->line(' - '.$issue); + } + if (count($issues) > 25) { + $this->line(' ... and '.(count($issues) - 25).' more'); + } + } else { + $this->info($doImport ? 'Import finished with no issues.' : 'No contract issues found. Feed looks ready.'); + } + + return ($skipped > 0 || $failed > 0) ? self::FAILURE : self::SUCCESS; + } + + private function validateItem(array $item): array + { + $rowIssues = []; + + if (empty($item['uid']) || ! isset($item['user'])) { + $rowIssues[] = 'missing uid or user'; + } + if (isset($item['uid']) && ! is_numeric($item['uid'])) { + $rowIssues[] = 'uid is not numeric'; + } + + $user = $item['user'] ?? null; + if (! is_array($user)) { + $rowIssues[] = 'user is not an object'; + } else { + if (empty(trim((string) ($user['email'] ?? '')))) { + $rowIssues[] = 'user.email empty'; + } + $userType = $user['type']['identifier'] ?? (is_string($user['type'] ?? null) ? $user['type'] : null); + if ($userType === null || trim((string) $userType) === '') { + $rowIssues[] = 'user.type.identifier missing'; + } + } + + if (empty($item['type']['identifier'] ?? null)) { + $rowIssues[] = 'type.identifier missing'; + } + + foreach (['title', 'description', 'organizer'] as $required) { + if (empty(trim((string) ($item[$required] ?? '')))) { + $rowIssues[] = "{$required} empty"; + } + } + + foreach (['eventStartDate', 'eventEndDate'] as $dateKey) { + $v = $item[$dateKey] ?? null; + if (! is_string($v) || ! preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $v)) { + $rowIssues[] = "{$dateKey} invalid"; + } + } + + $tags = $item['tags'] ?? []; + if (is_array($tags)) { + foreach ($tags as $tag) { + if (! is_array($tag) || ! array_key_exists('title', $tag)) { + $rowIssues[] = 'tags[] must be objects with title'; + break; + } + } + } elseif ($tags !== null) { + $rowIssues[] = 'tags must be an array'; + } + + return $rowIssues; + } + + private function mapAttrs(array $item, ?int $creatorId): array + { + $lat = isset($item['latitude']) && is_numeric($item['latitude']) ? (float) $item['latitude'] : null; + $lng = isset($item['longitude']) && is_numeric($item['longitude']) ? (float) $item['longitude'] : null; + $leading = trim((string) ($item['leading_teacher_tag'] ?? '')); + $cw4all = trim((string) ($item['codeweek_for_all_participation_code'] ?? '')); + $ages = is_array($item['ages'] ?? null) + ? array_values(array_intersect($item['ages'], Event::AGES)) + : null; + + $duration = $item['duration'] ?? null; + if ($duration !== null && ! in_array($duration, Event::DURATIONS, true)) { + $duration = null; + } + + $activityFormat = $item['activity_format'] ?? null; + if (is_string($activityFormat) && in_array($activityFormat, Event::ACTIVITY_FORMATS, true)) { + $activityFormat = [$activityFormat]; + } elseif (! is_array($activityFormat)) { + $activityFormat = null; + } else { + $activityFormat = array_values(array_intersect($activityFormat, Event::ACTIVITY_FORMATS)); + } + + return [ + 'status' => 'APPROVED', + 'title' => htmlspecialchars_decode((string) $item['title']), + 'slug' => Str::slug((string) $item['title']), + 'organizer' => (string) $item['organizer'], + 'description' => (string) $item['description'], + 'organizer_type' => (string) ($item['user']['type']['identifier'] ?? ($item['user']['type'] ?? 'other')), + 'activity_type' => (string) ($item['type']['identifier'] ?? 'invite-in-person'), + 'location' => (string) ($item['location'] ?? ''), + 'event_url' => (string) ($item['user']['www'] ?? ''), + 'contact_person' => (string) ($item['user']['publicEmail'] ?? ''), + 'user_email' => (string) ($item['user']['email'] ?? ''), + 'creator_id' => $creatorId, + 'country_iso' => strtoupper(trim((string) ($item['country'] ?? 'DE'))) ?: 'DE', + 'picture' => $item['photo'] ?? null, + 'language' => strtolower(trim((string) ($item['language'] ?? 'de'))) ?: 'de', + 'start_date' => $item['eventStartDate'], + 'end_date' => $item['eventEndDate'], + 'latitude' => $lat ?? 0.0, + 'longitude' => $lng ?? 0.0, + 'geoposition' => ($lat !== null && $lng !== null) ? "{$lat},{$lng}" : '', + 'participants_count' => isset($item['participants_count']) ? (int) $item['participants_count'] : null, + 'males_count' => isset($item['males_count']) ? (int) $item['males_count'] : null, + 'females_count' => isset($item['females_count']) ? (int) $item['females_count'] : null, + 'other_count' => isset($item['other_count']) ? (int) $item['other_count'] : null, + 'is_extracurricular_event' => (bool) ($item['is_extracurricular_event'] ?? false), + 'is_standard_school_curriculum' => (bool) ($item['is_standard_school_curriculum'] ?? false), + 'is_use_resource' => (bool) ($item['is_use_resource'] ?? false), + 'duration' => $duration, + 'activity_format' => $activityFormat, + 'ages' => $ages, + 'leading_teacher_tag' => $leading !== '' ? $leading : null, + 'codeweek_for_all_participation_code' => $cw4all !== '' ? $cw4all : 'cw-codeweek-de', + 'mass_added_for' => 'API codeweek_de', + 'source_ref' => self::FEED_KEY.':'.(int) $item['uid'], + 'source_synced_at' => now(), + ]; + } + + private function upsertEvent(array $item, int $fallbackCreatorId): string + { + $sourceRef = self::FEED_KEY.':'.(int) $item['uid']; + $email = trim((string) ($item['user']['email'] ?? '')); + + $user = User::where('email', $email)->first(); + if (! $user) { + $user = User::create([ + 'email' => $email, + 'firstname' => (string) ($item['organizer'] ?? 'Codeweek DE'), + 'lastname' => '', + 'username' => Str::slug((string) ($item['organizer'] ?? 'codeweek-de')).'-'.(int) $item['uid'], + 'password' => bcrypt(Str::random()), + ]); + } + + $attrs = $this->mapAttrs($item, $user->id ?: $fallbackCreatorId); + $attrs['creator_id'] = $user->id; + + $event = Event::where('source_ref', $sourceRef)->first(); + $created = false; + + if (! $event) { + $created = true; + $event = new Event($attrs); + $event->pub_date = now(); + $event->created = now(); + $event->updated = now(); + $event->save(); + } else { + $attrs['updated'] = now(); + // Keep existing picture if incoming is empty + if (empty($attrs['picture']) && ! empty($event->picture)) { + unset($attrs['picture']); + } + $event->fill($attrs); + $event->save(); + } + + $audienceIds = array_values(array_filter( + array_map('intval', Arr::pluck($item['audience'] ?? [], 'identifier')), + fn ($id) => $id > 0 && $id <= 100 + )); + $validAudienceIds = \App\Audience::whereIn('id', array_unique($audienceIds))->pluck('id')->all(); + $event->audiences()->sync($validAudienceIds); + + $themeIds = $this->validateThemes(Arr::pluck($item['themes'] ?? [], 'identifier')); + $event->themes()->sync($themeIds); + + $tagIds = []; + foreach (Arr::pluck($item['tags'] ?? [], 'title') as $name) { + $name = trim((string) $name); + if ($name === '') { + continue; + } + $tag = Tag::firstOrCreate(['name' => $name], ['slug' => Str::slug($name)]); + $tagIds[] = $tag->id; + } + $event->tags()->sync(array_unique($tagIds)); + + return $created ? 'created' : 'updated'; + } + + private function validateThemes(array $themeIds): array + { + $themeIds = array_unique(array_filter(array_map( + fn ($id) => is_numeric($id) ? (int) $id : null, + $themeIds + ))); + + $themeIdMapping = [ + 7 => 6, + 10 => 9, + 12 => 1, + 15 => 16, + ]; + + $mapped = array_map(fn ($id) => $themeIdMapping[$id] ?? $id, $themeIds); + + return Theme::whereIn('id', $mapped)->pluck('id')->all(); + } +} diff --git a/app/RSSItems/BadenRSSItem.php b/app/RSSItems/BadenRSSItem.php index efd004ab8..65c00fa70 100644 --- a/app/RSSItems/BadenRSSItem.php +++ b/app/RSSItems/BadenRSSItem.php @@ -28,6 +28,7 @@ class BadenRSSItem extends Model 'audience', 'themes', 'tags', + 'leading_teacher_tag', 'last_updated_at', ]; diff --git a/app/RSSItems/BayernRSSItem.php b/app/RSSItems/BayernRSSItem.php index 69ede2c5e..31ad17a02 100644 --- a/app/RSSItems/BayernRSSItem.php +++ b/app/RSSItems/BayernRSSItem.php @@ -28,6 +28,7 @@ class BayernRSSItem extends Model 'audience', 'themes', 'tags', + 'leading_teacher_tag', 'last_updated_at', ]; diff --git a/app/RSSItems/BerlinRSSItem.php b/app/RSSItems/BerlinRSSItem.php index d81269476..b53eaa87a 100644 --- a/app/RSSItems/BerlinRSSItem.php +++ b/app/RSSItems/BerlinRSSItem.php @@ -28,6 +28,7 @@ class BerlinRSSItem extends Model 'audience', 'themes', 'tags', + 'leading_teacher_tag', 'last_updated_at', ]; diff --git a/app/RSSItems/BonnRSSItem.php b/app/RSSItems/BonnRSSItem.php index f883cdd06..6fb7f87e4 100644 --- a/app/RSSItems/BonnRSSItem.php +++ b/app/RSSItems/BonnRSSItem.php @@ -28,6 +28,7 @@ class BonnRSSItem extends Model 'audience', 'themes', 'tags', + 'leading_teacher_tag', 'last_updated_at', ]; diff --git a/app/RSSItems/BremenRSSItem.php b/app/RSSItems/BremenRSSItem.php index 7232f0bb7..65a96e03f 100644 --- a/app/RSSItems/BremenRSSItem.php +++ b/app/RSSItems/BremenRSSItem.php @@ -28,6 +28,7 @@ class BremenRSSItem extends Model 'audience', 'themes', 'tags', + 'leading_teacher_tag', 'last_updated_at', ]; diff --git a/app/RSSItems/DresdenRSSItem.php b/app/RSSItems/DresdenRSSItem.php index ec4582f29..d29c45289 100644 --- a/app/RSSItems/DresdenRSSItem.php +++ b/app/RSSItems/DresdenRSSItem.php @@ -28,6 +28,7 @@ class DresdenRSSItem extends Model 'audience', 'themes', 'tags', + 'leading_teacher_tag', 'last_updated_at', ]; diff --git a/app/RSSItems/HamburgRSSItem.php b/app/RSSItems/HamburgRSSItem.php index 31313d2b3..8708bfedd 100644 --- a/app/RSSItems/HamburgRSSItem.php +++ b/app/RSSItems/HamburgRSSItem.php @@ -28,6 +28,7 @@ class HamburgRSSItem extends Model 'audience', 'themes', 'tags', + 'leading_teacher_tag', 'last_updated_at', ]; diff --git a/app/RSSItems/LeipzigRSSItem.php b/app/RSSItems/LeipzigRSSItem.php index 25216b283..70981c896 100644 --- a/app/RSSItems/LeipzigRSSItem.php +++ b/app/RSSItems/LeipzigRSSItem.php @@ -28,6 +28,7 @@ class LeipzigRSSItem extends Model 'audience', 'themes', 'tags', + 'leading_teacher_tag', 'last_updated_at', ]; diff --git a/app/RSSItems/MuensterlandRSSItem.php b/app/RSSItems/MuensterlandRSSItem.php index 461176dd1..b2e781b7e 100644 --- a/app/RSSItems/MuensterlandRSSItem.php +++ b/app/RSSItems/MuensterlandRSSItem.php @@ -28,6 +28,7 @@ class MuensterlandRSSItem extends Model 'audience', 'themes', 'tags', + 'leading_teacher_tag', 'last_updated_at', ]; diff --git a/app/RSSItems/NiedersachsenRSSItem.php b/app/RSSItems/NiedersachsenRSSItem.php index c4176adeb..73568712e 100644 --- a/app/RSSItems/NiedersachsenRSSItem.php +++ b/app/RSSItems/NiedersachsenRSSItem.php @@ -28,6 +28,7 @@ class NiedersachsenRSSItem extends Model 'audience', 'themes', 'tags', + 'leading_teacher_tag', 'last_updated_at', ]; diff --git a/app/RSSItems/NordhessenRSSItem.php b/app/RSSItems/NordhessenRSSItem.php index 5ba125417..3f3fd296b 100644 --- a/app/RSSItems/NordhessenRSSItem.php +++ b/app/RSSItems/NordhessenRSSItem.php @@ -28,6 +28,7 @@ class NordhessenRSSItem extends Model 'audience', 'themes', 'tags', + 'leading_teacher_tag', 'last_updated_at', ]; diff --git a/app/RSSItems/ThueringenRSSItem.php b/app/RSSItems/ThueringenRSSItem.php index 7da088641..38b274b37 100644 --- a/app/RSSItems/ThueringenRSSItem.php +++ b/app/RSSItems/ThueringenRSSItem.php @@ -28,6 +28,7 @@ class ThueringenRSSItem extends Model 'audience', 'themes', 'tags', + 'leading_teacher_tag', 'last_updated_at', ]; diff --git a/database/migrations/2026_09_02_160000_add_leading_teacher_tag_to_german_rss_items_tables.php b/database/migrations/2026_09_02_160000_add_leading_teacher_tag_to_german_rss_items_tables.php new file mode 100644 index 000000000..d0bc7528e --- /dev/null +++ b/database/migrations/2026_09_02_160000_add_leading_teacher_tag_to_german_rss_items_tables.php @@ -0,0 +1,51 @@ +tables as $table) { + if (! Schema::hasTable($table)) { + continue; + } + if (! Schema::hasColumn($table, 'leading_teacher_tag')) { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->string('leading_teacher_tag')->nullable()->after('tags'); + }); + } + } + } + + public function down(): void + { + foreach ($this->tables as $table) { + if (! Schema::hasTable($table)) { + continue; + } + if (Schema::hasColumn($table, 'leading_teacher_tag')) { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->dropColumn('leading_teacher_tag'); + }); + } + } + } +}; diff --git a/routes/console.php b/routes/console.php index 774cb0d49..45c86a8cd 100644 --- a/routes/console.php +++ b/routes/console.php @@ -22,8 +22,13 @@ 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); + Schedule::command('magic:key')->hourlyAt(13); Schedule::command('relocate')->hourlyAt(30);