From 6110c511eca3dc35bd0523c76da65ae095876855 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Fri, 4 Sep 2026 16:28:39 -0400 Subject: [PATCH] Add admin companies page and new-domain signup alerts Infer company domains from user emails (excluding consumer mailboxes), list them in Filament admin, and email accounts@nativephp.com when the first user registers on a new company domain. --- app/Filament/Pages/Companies.php | 117 ++++++++++++++++++ .../NotifyAccountsOfNewCompanyDomain.php | 33 +++++ .../NewCompanyDomainRegistered.php | 38 ++++++ app/Providers/EventServiceProvider.php | 2 + app/Services/CompanyAggregator.php | 74 +++++++++++ app/Support/ConsumerEmailDomains.php | 50 ++++++++ config/companies.php | 102 +++++++++++++++ tests/Feature/CompaniesAdminTest.php | 67 ++++++++++ .../NewCompanyDomainRegistrationTest.php | 78 ++++++++++++ tests/Unit/ConsumerEmailDomainsTest.php | 66 ++++++++++ 10 files changed, 627 insertions(+) create mode 100644 app/Filament/Pages/Companies.php create mode 100644 app/Listeners/NotifyAccountsOfNewCompanyDomain.php create mode 100644 app/Notifications/NewCompanyDomainRegistered.php create mode 100644 app/Services/CompanyAggregator.php create mode 100644 app/Support/ConsumerEmailDomains.php create mode 100644 config/companies.php create mode 100644 tests/Feature/CompaniesAdminTest.php create mode 100644 tests/Feature/NewCompanyDomainRegistrationTest.php create mode 100644 tests/Unit/ConsumerEmailDomainsTest.php diff --git a/app/Filament/Pages/Companies.php b/app/Filament/Pages/Companies.php new file mode 100644 index 000000000..5979001e2 --- /dev/null +++ b/app/Filament/Pages/Companies.php @@ -0,0 +1,117 @@ +records(function ( + ?string $search, + ?string $sortColumn, + ?string $sortDirection, + int $page, + int $recordsPerPage, + ): LengthAwarePaginator { + $records = app(CompanyAggregator::class)->aggregate(); + + $records = $records + ->when( + filled($search), + fn (Collection $data): Collection => $data->filter( + fn (array $record): bool => str_contains( + Str::lower($record['domain']), + Str::lower($search), + ), + ), + ); + + if (filled($sortColumn)) { + $records = $records->sortBy( + $sortColumn, + SORT_REGULAR, + ($sortDirection ?? 'desc') === 'desc', + ); + } else { + $records = $records->sortByDesc('users_count'); + } + + $records = $records->values(); + $total = $records->count(); + + $pageItems = $records + ->forPage($page, $recordsPerPage) + ->mapWithKeys(fn (array $record): array => [$record['domain'] => $record]); + + return new LengthAwarePaginator( + items: $pageItems, + total: $total, + perPage: $recordsPerPage, + currentPage: $page, + ); + }) + ->columns([ + TextColumn::make('domain') + ->label('Domain') + ->sortable() + ->copyable(), + TextColumn::make('users_count') + ->label('Users') + ->sortable() + ->numeric(), + TextColumn::make('earliest_signup') + ->label('Earliest signup') + ->dateTime() + ->sortable(), + TextColumn::make('latest_signup') + ->label('Latest signup') + ->dateTime() + ->sortable(), + ]) + ->defaultSort('users_count', 'desc') + ->searchable() + ->actions([ + Action::make('viewUsers') + ->label('Users') + ->icon('heroicon-o-users') + ->color('gray') + ->url(fn (array $record): string => UserResource::getUrl('index', [ + 'tableSearch' => $record['domain'], + ])), + ]) + ->paginated([10, 25, 50, 100]); + } + + public function content(Schema $schema): Schema + { + return $schema + ->components([ + EmbeddedTable::make(), + ]); + } +} diff --git a/app/Listeners/NotifyAccountsOfNewCompanyDomain.php b/app/Listeners/NotifyAccountsOfNewCompanyDomain.php new file mode 100644 index 000000000..48309ed91 --- /dev/null +++ b/app/Listeners/NotifyAccountsOfNewCompanyDomain.php @@ -0,0 +1,33 @@ +user; + $domain = ConsumerEmailDomains::domainFromEmail($user->email); + + if (! ConsumerEmailDomains::isCompanyDomain($domain)) { + return; + } + + if ($this->companies->countUsersForDomain($domain) !== 1) { + return; + } + + Notification::route('mail', config('companies.accounts_email')) + ->notify(new NewCompanyDomainRegistered($user, $domain)); + } +} diff --git a/app/Notifications/NewCompanyDomainRegistered.php b/app/Notifications/NewCompanyDomainRegistered.php new file mode 100644 index 000000000..0e125ea06 --- /dev/null +++ b/app/Notifications/NewCompanyDomainRegistered.php @@ -0,0 +1,38 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('New company domain: '.$this->domain) + ->greeting('A new company domain just signed up.') + ->line("**Domain:** {$this->domain}") + ->line("**Name:** {$this->user->name}") + ->line("**Email:** {$this->user->email}") + ->line('**Signed up:** '.$this->user->created_at?->toDayDateTimeString()); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index f6e8b1ccb..5b2b623de 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Listeners\NotifyAccountsOfNewCompanyDomain; use App\Listeners\StripeWebhookHandledListener; use App\Listeners\StripeWebhookReceivedListener; use App\Listeners\SuppressMailNotificationListener; @@ -23,6 +24,7 @@ class EventServiceProvider extends ServiceProvider protected $listen = [ Registered::class => [ SendEmailVerificationNotification::class, + NotifyAccountsOfNewCompanyDomain::class, ], WebhookReceived::class => [ StripeWebhookReceivedListener::class, diff --git a/app/Services/CompanyAggregator.php b/app/Services/CompanyAggregator.php new file mode 100644 index 000000000..0b504b554 --- /dev/null +++ b/app/Services/CompanyAggregator.php @@ -0,0 +1,74 @@ + + */ + public function aggregate(): Collection + { + $domainExpression = $this->domainExpression(); + + $rows = User::query() + ->select([ + DB::raw("{$domainExpression} as domain"), + DB::raw('COUNT(*) as users_count'), + DB::raw('MIN(created_at) as earliest_signup'), + DB::raw('MAX(created_at) as latest_signup'), + ]) + ->whereNotNull('email') + ->where('email', 'like', '%@%') + ->groupBy(DB::raw($domainExpression)) + ->get(); + + return $rows + ->filter(fn ($row): bool => ConsumerEmailDomains::isCompanyDomain($row->domain)) + ->mapWithKeys(fn ($row): array => [ + $row->domain => [ + 'domain' => $row->domain, + 'users_count' => (int) $row->users_count, + 'earliest_signup' => $row->earliest_signup, + 'latest_signup' => $row->latest_signup, + ], + ]); + } + + /** + * @return Collection + */ + public function usersForDomain(string $domain): Collection + { + $domain = strtolower($domain); + $domainExpression = $this->domainExpression(); + + return User::query() + ->whereRaw("{$domainExpression} = ?", [$domain]) + ->orderBy('created_at') + ->get(); + } + + public function countUsersForDomain(string $domain): int + { + $domain = strtolower($domain); + $domainExpression = $this->domainExpression(); + + return User::query() + ->whereRaw("{$domainExpression} = ?", [$domain]) + ->count(); + } + + protected function domainExpression(): string + { + return match (DB::connection()->getDriverName()) { + 'sqlite' => "lower(substr(email, instr(email, '@') + 1))", + default => "LOWER(SUBSTRING_INDEX(email, '@', -1))", + }; + } +} diff --git a/app/Support/ConsumerEmailDomains.php b/app/Support/ConsumerEmailDomains.php new file mode 100644 index 000000000..bfd7d7179 --- /dev/null +++ b/app/Support/ConsumerEmailDomains.php @@ -0,0 +1,50 @@ + env('COMPANIES_ACCOUNTS_EMAIL', 'accounts@nativephp.com'), + + /* + |-------------------------------------------------------------------------- + | Consumer / free mailbox domains + |-------------------------------------------------------------------------- + | + | Exact domains that should never be treated as company domains. + | + */ + + 'consumer_domains' => [ + 'gmail.com', + 'googlemail.com', + 'outlook.com', + 'live.com', + 'msn.com', + 'icloud.com', + 'me.com', + 'mac.com', + 'aol.com', + 'protonmail.com', + 'proton.me', + 'hey.com', + 'mail.com', + 'qq.com', + '163.com', + '126.com', + 'yeah.net', + 'ymail.com', + 'rocketmail.com', + 'mail.ru', + 'inbox.com', + 'fastmail.com', + 'fastmail.fm', + 'tutanota.com', + 'tuta.io', + 'zoho.com', + 'zohomail.com', + 'gmx.com', + 'gmx.net', + 'gmx.de', + 'gmx.at', + 'gmx.ch', + 'gmx.fr', + 'gmx.co.uk', + 'hotmail.com', + 'hotmail.co.uk', + 'hotmail.fr', + 'hotmail.de', + 'hotmail.it', + 'hotmail.es', + 'hotmail.ca', + 'yahoo.com', + 'yahoo.co.uk', + 'yahoo.fr', + 'yahoo.de', + 'yahoo.it', + 'yahoo.es', + 'yahoo.ca', + 'yahoo.com.au', + 'yahoo.co.in', + 'yahoo.co.jp', + 'yandex.com', + 'yandex.ru', + 'yandex.ua', + 'yandex.by', + 'yandex.kz', + ], + + /* + |-------------------------------------------------------------------------- + | Consumer domain prefixes + |-------------------------------------------------------------------------- + | + | Domains that start with these prefixes (e.g. yahoo.co.uk, hotmail.fr, + | gmx.de, yandex.ru) are also treated as consumer mailboxes. + | + */ + + 'consumer_domain_prefixes' => [ + 'yahoo.', + 'hotmail.', + 'gmx.', + 'yandex.', + ], + +]; diff --git a/tests/Feature/CompaniesAdminTest.php b/tests/Feature/CompaniesAdminTest.php new file mode 100644 index 000000000..db80080bc --- /dev/null +++ b/tests/Feature/CompaniesAdminTest.php @@ -0,0 +1,67 @@ +admin = User::factory()->create(['email' => 'admin@nativephp.com']); + config(['filament.users' => ['admin@nativephp.com']]); + } + + public function test_aggregator_groups_users_by_company_domain_and_excludes_consumer_domains(): void + { + User::factory()->create(['email' => 'a@acme.com', 'created_at' => now()->subDays(3)]); + User::factory()->create(['email' => 'b@acme.com', 'created_at' => now()->subDay()]); + User::factory()->create(['email' => 'c@widgets.io', 'created_at' => now()]); + User::factory()->create(['email' => 'person@gmail.com']); + User::factory()->create(['email' => 'person@yahoo.co.uk']); + + $companies = app(CompanyAggregator::class)->aggregate(); + + $this->assertTrue($companies->has('acme.com')); + $this->assertTrue($companies->has('widgets.io')); + $this->assertFalse($companies->has('gmail.com')); + $this->assertFalse($companies->has('yahoo.co.uk')); + $this->assertSame(2, $companies['acme.com']['users_count']); + $this->assertSame(1, $companies['widgets.io']['users_count']); + } + + public function test_admin_companies_page_lists_company_domains_and_excludes_consumer_mailboxes(): void + { + User::factory()->create(['email' => 'a@acme.com']); + User::factory()->create(['email' => 'b@acme.com']); + User::factory()->create(['email' => 'c@widgets.io']); + User::factory()->create(['email' => 'd@gmail.com']); + + Livewire::actingAs($this->admin) + ->test(Companies::class) + ->assertCanSeeTableRecords(['acme.com', 'widgets.io']) + ->assertCanNotSeeTableRecords(['gmail.com']) + ->assertSee('acme.com') + ->assertSee('widgets.io') + ->assertDontSee('gmail.com'); + } + + public function test_admin_companies_page_is_accessible_to_admins(): void + { + $this->actingAs($this->admin) + ->get(Companies::getUrl()) + ->assertOk() + ->assertSee('Companies'); + } +} diff --git a/tests/Feature/NewCompanyDomainRegistrationTest.php b/tests/Feature/NewCompanyDomainRegistrationTest.php new file mode 100644 index 000000000..3631be075 --- /dev/null +++ b/tests/Feature/NewCompanyDomainRegistrationTest.php @@ -0,0 +1,78 @@ + $overrides + * @return array + */ + private function registrationPayload(array $overrides = []): array + { + return array_merge([ + 'name' => 'Ada Lovelace', + 'email' => 'ada@acme.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ], $overrides); + } + + public function test_first_company_domain_signup_emails_accounts(): void + { + Notification::fake(); + config(['services.turnstile.secret_key' => null]); + + $response = $this->post('/register', $this->registrationPayload()); + + $response->assertRedirect(route('dashboard')); + $this->assertAuthenticated(); + + Notification::assertSentOnDemand( + NewCompanyDomainRegistered::class, + function (NewCompanyDomainRegistered $notification, array $channels, object $notifiable): bool { + return $notifiable->routes['mail'] === 'accounts@nativephp.com' + && $notification->domain === 'acme.com' + && $notification->user->email === 'ada@acme.com'; + } + ); + } + + public function test_second_user_on_same_company_domain_does_not_email_accounts(): void + { + Notification::fake(); + config(['services.turnstile.secret_key' => null]); + + User::factory()->create(['email' => 'first@acme.com']); + + $response = $this->post('/register', $this->registrationPayload([ + 'email' => 'second@acme.com', + ])); + + $response->assertRedirect(route('dashboard')); + + Notification::assertSentOnDemandTimes(NewCompanyDomainRegistered::class, 0); + } + + public function test_gmail_signup_does_not_email_accounts(): void + { + Notification::fake(); + config(['services.turnstile.secret_key' => null]); + + $response = $this->post('/register', $this->registrationPayload([ + 'email' => 'person@gmail.com', + ])); + + $response->assertRedirect(route('dashboard')); + + Notification::assertSentOnDemandTimes(NewCompanyDomainRegistered::class, 0); + } +} diff --git a/tests/Unit/ConsumerEmailDomainsTest.php b/tests/Unit/ConsumerEmailDomainsTest.php new file mode 100644 index 000000000..42c074315 --- /dev/null +++ b/tests/Unit/ConsumerEmailDomainsTest.php @@ -0,0 +1,66 @@ +assertSame('acme.com', ConsumerEmailDomains::extract('Ada@Acme.com')); + } + + public function test_it_returns_null_for_invalid_emails(): void + { + $this->assertNull(ConsumerEmailDomains::extract(null)); + $this->assertNull(ConsumerEmailDomains::extract('')); + $this->assertNull(ConsumerEmailDomains::extract('not-an-email')); + } + + #[DataProvider('consumerDomains')] + public function test_it_recognizes_consumer_domains(string $domain): void + { + $this->assertTrue(ConsumerEmailDomains::isConsumer($domain)); + $this->assertFalse(ConsumerEmailDomains::isCompanyDomain($domain)); + } + + #[DataProvider('companyDomains')] + public function test_it_recognizes_company_domains(string $domain): void + { + $this->assertFalse(ConsumerEmailDomains::isConsumer($domain)); + $this->assertTrue(ConsumerEmailDomains::isCompanyDomain($domain)); + } + + /** + * @return array + */ + public static function consumerDomains(): array + { + return [ + 'gmail' => ['gmail.com'], + 'googlemail' => ['googlemail.com'], + 'yahoo prefix' => ['yahoo.co.uk'], + 'hotmail prefix' => ['hotmail.fr'], + 'outlook' => ['outlook.com'], + 'icloud' => ['icloud.com'], + 'proton' => ['proton.me'], + 'gmx prefix' => ['gmx.de'], + 'yandex prefix' => ['yandex.ru'], + ]; + } + + /** + * @return array + */ + public static function companyDomains(): array + { + return [ + 'acme' => ['acme.com'], + 'nativephp' => ['nativephp.com'], + 'laravel' => ['laravel.com'], + ]; + } +}