diff --git a/.ai/mcp/mcp.json b/.ai/mcp/mcp.json new file mode 100644 index 00000000..e69de29b diff --git a/app/Console/Commands/AuditMorphColumns.php b/app/Console/Commands/AuditMorphColumns.php deleted file mode 100644 index fc7d56f6..00000000 --- a/app/Console/Commands/AuditMorphColumns.php +++ /dev/null @@ -1,94 +0,0 @@ - 'model_type', - 'wallets' => 'owner_type', - 'transactions' => 'reference_type', - 'events_agenda' => 'schedulable_type', - 'media' => 'model_type', - 'notifications' => 'notifiable_type', - 'personal_access_tokens' => 'tokenable_type', - ]; - - protected $signature = 'morph:audit'; - - protected $description = 'Audit all polymorphic *_type columns and report distinct values'; - - public function handle(): void - { - intro('Polymorphic Column Audit'); - - $allRows = []; - - foreach (self::MORPH_TABLES as $tableName => $column) { - if (! Schema::hasTable($tableName)) { - $allRows[] = [$tableName, $column, '(table not found)', '-']; - - continue; - } - - if (! Schema::hasColumn($tableName, $column)) { - $allRows[] = [$tableName, $column, '(column not found)', '-']; - - continue; - } - - $distinctValues = DB::table($tableName) - ->select($column, DB::raw('COUNT(*) as cnt')) - ->groupBy($column) - ->orderByDesc('cnt') - ->get(); - - if ($distinctValues->isEmpty()) { - $allRows[] = [$tableName, $column, '(empty table)', '0']; - - continue; - } - - foreach ($distinctValues as $row) { - $value = $row->{$column} ?? '(NULL)'; - $allRows[] = [$tableName, $column, $value, number_format($row->cnt)]; - } - } - - table( - headers: ['Table', 'Column', 'Distinct Value', 'Count'], - rows: $allRows, - ); - - // Summary - $fqcnCount = 0; - $aliasCount = 0; - foreach ($allRows as $row) { - if (str_contains($row[2], '\\')) { - $fqcnCount++; - } elseif (! str_starts_with($row[2], '(')) { - $aliasCount++; - } - } - - info(sprintf('FQCN entries: %d | Alias entries: %d', $fqcnCount, $aliasCount)); - - if ($fqcnCount > 0) { - warning('Database contains FQCN values that need migration to aliases.'); - } else { - outro('All morph columns use aliases. Ready for enforceMorphMap.'); - } - } -} diff --git a/database/migrations/2026_03_21_192332_deduplicate_external_identities.php b/database/migrations/2026_03_21_192332_deduplicate_external_identities.php index 04899913..ed17acc0 100644 --- a/database/migrations/2026_03_21_192332_deduplicate_external_identities.php +++ b/database/migrations/2026_03_21_192332_deduplicate_external_identities.php @@ -4,6 +4,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; return new class extends Migration { @@ -37,14 +38,35 @@ public function up(): void ->where('external_identity_id', $dup->old_ei_id) ->update(['external_identity_id' => $dup->new_ei_id]); - $oldXp = DB::table('characters') + $oldCharacter = DB::table('characters') ->where('user_id', $dup->old_user_id) - ->value('experience') ?? 0; + ->select(['experience', 'tenant_id']) + ->first(); + + $oldXp = $oldCharacter->experience ?? 0; if ($oldXp > 0) { - DB::table('characters') + $existingCharacter = DB::table('characters') ->where('user_id', $dup->new_user_id) - ->increment('experience', $oldXp); + ->exists(); + + if ($existingCharacter) { + DB::table('characters') + ->where('user_id', $dup->new_user_id) + ->increment('experience', $oldXp); + } else { + logger()->warning(sprintf('Migration: Creating missing character for user %s with %s XP from old user %s', $dup->new_user_id, $oldXp, $dup->old_user_id)); + + DB::table('characters')->insert([ + 'id' => Str::uuid()->toString(), + 'user_id' => $dup->new_user_id, + 'tenant_id' => $oldCharacter->tenant_id, + 'experience' => $oldXp, + 'reputation' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } } DB::table('external_identities') diff --git a/database/migrations/2026_03_22_000000_normalize_github_urls.php b/database/migrations/2026_03_22_000000_normalize_github_urls.php new file mode 100644 index 00000000..dfb87d35 --- /dev/null +++ b/database/migrations/2026_03_22_000000_normalize_github_urls.php @@ -0,0 +1,126 @@ +whereNotNull('github_url') + ->where('github_url', '!=', '') + ->whereRaw("NOT (github_url LIKE 'https://github.com/%' AND LENGTH(github_url) > 19)") + ->get(['id', 'github_url']); + + foreach ($rows as $row) { + $lower = mb_strtolower(mb_trim($row->github_url)); + + if ($this->isJunk($lower)) { + DB::table('user_information')->where('id', $row->id)->update(['github_url' => null]); + + continue; + } + + $fixed = $this->tryFixUrl($lower); + + if ($fixed !== null) { + DB::table('user_information')->where('id', $row->id)->update(['github_url' => $fixed]); + + continue; + } + + // Non-github links, gifs, random URLs — null them out + DB::table('user_information')->where('id', $row->id)->update(['github_url' => null]); + } + } + + public function down(): void + { + // Irreversible: original junk values are not preserved. + } + + private function isJunk(string $lower): bool + { + if (in_array($lower, self::JUNK_EXACT, true)) { + return true; + } + + foreach (self::JUNK_KEYWORDS as $keyword) { + if (str_contains($lower, $keyword)) { + return true; + } + } + + return false; + } + + private function tryFixUrl(string $lower): ?string + { + if (preg_match('#^github\.com/[\w.\-]+#', $lower)) { + return 'https://'.$lower; + } + + if (preg_match('#^(https?://)?www\.github\.com/([\w.\-]+)#i', $lower, $m)) { + return 'https://github.com/'.$m[2]; + } + + if (preg_match('#^https?://git[Hh]ub\.com/([\w.\-]+)#', $lower, $m)) { + return 'https://github.com/'.$m[1]; + } + + if (preg_match('#^https?//github\.com/([\w.\-]+)#', $lower, $m)) { + return 'https://github.com/'.$m[1]; + } + + if (preg_match('#^https?;//github\.com/([\w.\-]+)#', $lower, $m)) { + return 'https://github.com/'.$m[1]; + } + + if (preg_match('#^https?:+//github\.com/([\w.\-]+)#', $lower, $m)) { + return 'https://github.com/'.$m[1]; + } + + if (preg_match('#^h\w{3,6}s?[:/]+/*github\.com/([\w.\-]+)#', $lower, $m)) { + return 'https://github.com/'.$m[1]; + } + + if (preg_match('#^https?://(?:gi[th]*ub|gu[th]*ub|githubm)\.com/([\w.\-]+)#', $lower, $m)) { + return 'https://github.com/'.$m[1]; + } + + if (preg_match('#^https?://github,com/([\w.\-]+)#', $lower, $m)) { + return 'https://github.com/'.$m[1]; + } + + return null; + } +}; diff --git a/database/migrations/2026_03_22_000001_normalize_linkedin_urls.php b/database/migrations/2026_03_22_000001_normalize_linkedin_urls.php new file mode 100644 index 00000000..3e948bdf --- /dev/null +++ b/database/migrations/2026_03_22_000001_normalize_linkedin_urls.php @@ -0,0 +1,140 @@ +whereNotNull('linkedin_url') + ->where('linkedin_url', '!=', '') + ->whereRaw("NOT (linkedin_url LIKE 'https://linkedin.com/in/%')") + ->whereRaw("NOT (linkedin_url LIKE 'https://www.linkedin.com/in/%')") + ->get(['id', 'linkedin_url']); + + foreach ($rows as $row) { + $lower = mb_strtolower(mb_trim($row->linkedin_url)); + + if ($this->isJunk($lower)) { + DB::table('user_information')->where('id', $row->id)->update(['linkedin_url' => null]); + + continue; + } + + $fixed = $this->tryFixUrl($lower); + + if ($fixed !== null) { + DB::table('user_information')->where('id', $row->id)->update(['linkedin_url' => $fixed]); + + continue; + } + + DB::table('user_information')->where('id', $row->id)->update(['linkedin_url' => null]); + } + + // Phase 2: Normalize www → no www + strip trailing slash + $wwwRows = DB::table('user_information') + ->whereNotNull('linkedin_url') + ->where('linkedin_url', 'LIKE', 'https://www.linkedin.com/in/%') + ->get(['id', 'linkedin_url']); + + foreach ($wwwRows as $row) { + $normalized = str_replace('https://www.linkedin.com/in/', 'https://linkedin.com/in/', $row->linkedin_url); + $normalized = mb_rtrim($normalized, '/'); + + DB::table('user_information')->where('id', $row->id)->update(['linkedin_url' => $normalized]); + } + + // Phase 3: Strip trailing slash from already-correct URLs + $trailingSlash = DB::table('user_information') + ->whereNotNull('linkedin_url') + ->where('linkedin_url', 'LIKE', '%/') + ->get(['id', 'linkedin_url']); + + foreach ($trailingSlash as $row) { + DB::table('user_information')->where('id', $row->id)->update([ + 'linkedin_url' => mb_rtrim($row->linkedin_url, '/'), + ]); + } + + // Phase 4: Null bare URL without handle + DB::table('user_information') + ->where('linkedin_url', 'https://linkedin.com/in') + ->update(['linkedin_url' => null]); + } + + public function down(): void + { + // Irreversible + } + + private function isJunk(string $lower): bool + { + if (in_array($lower, self::JUNK_EXACT, true)) { + return true; + } + + foreach (self::JUNK_KEYWORDS as $keyword) { + if (str_contains($lower, $keyword)) { + return true; + } + } + + return false; + } + + private function tryFixUrl(string $lower): ?string + { + if (preg_match('#linkedin\.com/in\s*/?([\w\-%.]+)#', $lower, $m)) { + return 'https://linkedin.com/in/'.mb_rtrim($m[1], '/'); + } + + if (preg_match('#linkedin\.com/mwlite/in/([\w\-%.]+)#', $lower, $m)) { + return 'https://linkedin.com/in/'.mb_rtrim($m[1], '/'); + } + + if (preg_match('#^https?://(?:www\.)?linkedin\.com/([\w\-]{3,})$#', $lower, $m)) { + $path = $m[1]; + if (! in_array($path, ['feed', 'jobs', 'company', 'me', 'messaging', 'notifications', 'search'], true)) { + return 'https://linkedin.com/in/'.$path; + } + } + + if (preg_match('#^(?:www\.)?linkedin\.com/in/([\w\-%.]+)#', $lower, $m)) { + return 'https://linkedin.com/in/'.mb_rtrim($m[1], '/'); + } + + return null; + } +};