assignmentRows(Carbon::parse((string) $this->option('since'))->startOfDay()); $foreign = $rows->reject(fn (object $row): bool => $row->allowed)->values(); $this->info(sprintf( '%d über die API-Zuordnung entstandene Mitgliedschaft(en), davon %d ohne Freischaltung.', $rows->count(), $foreign->count(), )); if ($rows->isNotEmpty()) { $this->table( ['Firma', 'Firmenname', 'User', 'E-Mail', 'Herkunft', 'Angelegt', 'Freigeschaltet'], $rows->take(100)->map(fn (object $row): array => $this->tableRow($row))->all(), ); } if (! $this->option('remove')) { if ($foreign->isNotEmpty()) { $this->line('Entfernen mit --remove (zeigt vorher die betroffenen Zeilen).'); } return self::SUCCESS; } if ($foreign->isEmpty()) { $this->info('Nichts zu entfernen.'); return self::SUCCESS; } $this->warn(sprintf('Zu entfernen (%d):', $foreign->count())); $this->table( ['Firma', 'Firmenname', 'User', 'E-Mail', 'Herkunft', 'Angelegt', 'Freigeschaltet'], $foreign->map(fn (object $row): array => $this->tableRow($row))->all(), ); if (! $this->option('force') && ! $this->confirm(sprintf('%d Mitgliedschaft(en) entfernen?', $foreign->count()))) { $this->line('Abgebrochen, nichts geändert.'); return self::SUCCESS; } $removed = 0; foreach ($foreign as $row) { $deleted = DB::table('company_user') ->where('company_id', $row->company_id) ->where('user_id', $row->user_id) ->where('role', 'member') ->delete(); if ($deleted === 0) { continue; } $removed++; $activityLog->record( ActivityType::Correction, sprintf('API-Mitgliedschaft von User #%d (%s) an Firma #%d „%s“ entfernt (Audit R-01)', $row->user_id, $row->email, $row->company_id, $row->company_name), subject: Company::withoutGlobalScope(PortalScope::class)->find($row->company_id), meta: ['api_assignment_removed' => true, 'user_id' => $row->user_id, 'granted_via' => $row->granted_via], actorLabel: 'console', ); } $this->info(sprintf('%d Mitgliedschaft(en) entfernt.', $removed)); return self::SUCCESS; } /** * Pivots aus der API-Zuordnung: aus dem Aktivitäts-Log (Altfälle ohne * Herkunft) und mit `granted_via = api` (ab dem Fix). * * @return Collection */ private function assignmentRows(Carbon $since): Collection { /** @var array $firstEvents "company:user" => erste Zuordnung */ $firstEvents = []; ActivityEvent::query() ->where('type', ActivityType::Correction->value) ->where('subject_type', (new Company)->getMorphClass()) ->where('description', 'like', 'API-Firma %') ->where('occurred_at', '>=', $since) ->whereNotNull('user_id') ->orderBy('id') ->select(['id', 'user_id', 'subject_id', 'occurred_at']) ->chunkById(1000, function (Collection $events) use (&$firstEvents): void { foreach ($events as $event) { $key = $event->subject_id.':'.$event->user_id; $firstEvents[$key] ??= $event->occurred_at; } }); $pairs = collect(array_keys($firstEvents))->map(fn (string $key): array => array_map('intval', explode(':', $key))); $rows = collect(); foreach ($pairs->chunk(500) as $chunk) { $rows = $rows->merge($this->pivotQuery() ->where(function ($query) use ($chunk): void { foreach ($chunk as [$companyId, $userId]) { $query->orWhere(fn ($pair) => $pair->where('company_user.company_id', $companyId)->where('company_user.user_id', $userId)); } }) ->get()); } $rows = $rows->merge($this->pivotQuery()->where('company_user.granted_via', ApiNewsroomAssignment::GRANTED_VIA)->get()); return $rows ->unique(fn (object $row): string => $row->company_id.':'.$row->user_id) ->filter(function (object $row) use ($firstEvents): bool { if ((int) $row->owner_user_id === (int) $row->user_id) { return false; } if ($row->granted_via === ApiNewsroomAssignment::GRANTED_VIA) { return true; } // Schon vor der ersten Zuordnung Mitglied: nicht durch die API entstanden. $first = $firstEvents[$row->company_id.':'.$row->user_id] ?? null; return $first !== null && $row->created_at !== null && Carbon::parse($row->created_at)->greaterThanOrEqualTo($first->copy()->subSeconds(self::EVENT_TOLERANCE_SECONDS)); }) ->map(function (object $row): object { $row->allowed = ApiNewsroomAssignment::isAllowed((int) $row->user_id); return $row; }) ->sortBy(['company_id', 'user_id']) ->values(); } /** * `member`-Pivots samt Firma und Nutzer — `responsible`/`owner` stammen * nie aus der API-Zuordnung (eine Hochstufung hat eine E-Mail-Grundlage). */ private function pivotQuery(): Builder { return DB::table('company_user') ->join('companies', 'companies.id', '=', 'company_user.company_id') ->join('users', 'users.id', '=', 'company_user.user_id') ->where('company_user.role', 'member') ->select([ 'company_user.company_id', 'company_user.user_id', 'company_user.granted_via', 'company_user.created_at', 'companies.name as company_name', 'companies.owner_user_id', 'users.email', ]); } /** * @return list */ private function tableRow(object $row): array { return [ (string) $row->company_id, mb_strimwidth((string) $row->company_name, 0, 40, '…'), (string) $row->user_id, (string) $row->email, (string) $row->granted_via, (string) $row->created_at, $row->allowed ? 'ja' : 'nein', ]; } }