diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..1213d3fe3 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 3d1dd254f..95c570805 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ resume-monitoring.sh .envault.json .phpunit.result.cache /docs/_build/ +.DS_Store \ No newline at end of file diff --git a/app/Console/Commands/ImportAllModels.php b/app/Console/Commands/ImportAllModels.php index 2e919dbdd..e01f6facd 100644 --- a/app/Console/Commands/ImportAllModels.php +++ b/app/Console/Commands/ImportAllModels.php @@ -9,6 +9,9 @@ use App\Models\Attendance; use App\Models\DuesTransaction; use App\Models\Event; +use App\Models\Sponsor; +use App\Models\SponsorDomain; +use App\Models\SponsorUser; use App\Models\Team; use App\Models\Travel; use App\Models\TravelAssignment; @@ -57,6 +60,18 @@ public function handle(): int 'model' => Event::class, ]); + $this->call('scout:import', [ + 'model' => Sponsor::class, + ]); + + $this->call('scout:import', [ + 'model' => SponsorDomain::class, + ]); + + $this->call('scout:import', [ + 'model' => SponsorUser::class, + ]); + $this->call('scout:import', [ 'model' => Team::class, ]); diff --git a/app/Models/Sponsor.php b/app/Models/Sponsor.php index d53b5dac8..0526bcf86 100644 --- a/app/Models/Sponsor.php +++ b/app/Models/Sponsor.php @@ -74,4 +74,18 @@ public function users(): HasMany { return $this->hasMany(SponsorUser::class, 'sponsor_id'); } + + /** + * Get the indexable data array for the model. + * + * @return array + */ + public function toSearchableArray(): array + { + $array = $this->toArray(); + + $array['end_date_unix'] = $this->end_date->getTimestamp(); + + return $array; + } } diff --git a/app/Nova/Sponsor.php b/app/Nova/Sponsor.php new file mode 100644 index 000000000..d95439de4 --- /dev/null +++ b/app/Nova/Sponsor.php @@ -0,0 +1,87 @@ + + */ +class Sponsor extends Resource +{ + /** + * The model the resource corresponds to. + * + * @var class-string<\App\Models\Sponsor> + */ + public static $model = AppModelsSponsor::class; + + /** + * The single value that should be used to represent the resource when being displayed. + * + * @var string + */ + public static $title = 'name'; + + /** + * The logical group associated with the resource. + * + * @var string + */ + public static $group = 'Other'; + + /** + * The columns that should be searched. + * + * @var array + */ + public static $search = [ + 'id', + 'name', + ]; + + /** + * The number of results to display in the global search. + * + * @var int + */ + public static $globalSearchResults = 5; + + /** + * The number of results to display when searching the resource using Scout. + * + * @var int + */ + public static $scoutSearchResults = 5; + + /** + * Get the fields displayed by the resource. + */ + #[\Override] + public function fields(NovaRequest $request): array + { + return [ + Text::make('Name') + ->rules('required', 'max:255') + ->creationRules('unique:sponsors,name') + ->updateRules('unique:sponsors,name,{{resourceId}}') + ->sortable(), + DateTime::make('End Date') + ->rules('required') + ->sortable(), + HasMany::make('Domain Names', 'domainNames', SponsorDomain::class), + HasMany::make('Users', 'users', SponsorUser::class), + Boolean::make('Active', fn () => $this->active())->onlyOnIndex(), + ]; + } +} diff --git a/app/Nova/SponsorDomain.php b/app/Nova/SponsorDomain.php new file mode 100644 index 000000000..71b055538 --- /dev/null +++ b/app/Nova/SponsorDomain.php @@ -0,0 +1,83 @@ + + */ +class SponsorDomain extends Resource +{ + /** + * The model the resource corresponds to. + * + * @var class-string<\App\Models\SponsorDomain> + */ + public static $model = AppModelsSponsorDomain::class; + + /** + * The single value that should be used to represent the resource when being displayed. + * + * @var string + */ + public static $title = 'domain_name'; + + /** + * The logical group associated with the resource. + * + * @var string + */ + public static $group = 'Other'; + + /** + * The columns that should be searched. + * + * @var array + */ + public static $search = [ + 'id', + 'name', + ]; + + /** + * The number of results to display in the global search. + * + * @var int + */ + public static $globalSearchResults = 5; + + /** + * The number of results to display when searching the resource using Scout. + * + * @var int + */ + public static $scoutSearchResults = 5; + + /** + * Get the fields displayed by the resource. + */ + #[\Override] + public function fields(NovaRequest $request): array + { + return [ + BelongsTo::make('Sponsor', 'sponsor', Sponsor::class) + ->withoutTrashed() + ->rules('required') + ->sortable(), + Text::make('Domain Name', 'domain_name') + ->rules('required', 'max:255') + ->creationRules('unique:sponsor_domains,domain_name') + ->updateRules('unique:sponsor_domains,domain_name,{{resourceId}}') + ->sortable(), + ]; + } +} diff --git a/app/Nova/SponsorUser.php b/app/Nova/SponsorUser.php new file mode 100644 index 000000000..61a8cfff8 --- /dev/null +++ b/app/Nova/SponsorUser.php @@ -0,0 +1,84 @@ + + */ +class SponsorUser extends Resource +{ + /** + * The model the resource corresponds to. + * + * @var class-string<\App\Models\SponsorUser> + */ + public static $model = AppModelsSponsorUser::class; + + /** + * The single value that should be used to represent the resource when being displayed. + * + * @var string + */ + public static $title = 'email'; + + /** + * The logical group associated with the resource. + * + * @var string + */ + public static $group = 'Other'; + + /** + * The columns that should be searched. + * + * @var array + */ + public static $search = [ + 'id', + 'name', + ]; + + /** + * The number of results to display in the global search. + * + * @var int + */ + public static $globalSearchResults = 5; + + /** + * The number of results to display when searching the resource using Scout. + * + * @var int + */ + public static $scoutSearchResults = 5; + + /** + * Get the fields displayed by the resource. + */ + #[\Override] + public function fields(NovaRequest $request): array + { + return [ + BelongsTo::make('Sponsor', 'company', Sponsor::class) + ->withoutTrashed() + ->rules('required') + ->sortable(), + Text::make('Email', 'email') + ->rules('required', 'email:rfc,strict,dns,spoof', 'max:255', new SponsorUserValidEmail()) + ->creationRules('unique:sponsor_users,email') + ->updateRules('unique:sponsor_users,email,{{resourceId}}') + ->sortable(), + ]; + } +} diff --git a/app/Policies/SponsorDomainPolicy.php b/app/Policies/SponsorDomainPolicy.php new file mode 100644 index 000000000..16e2668e6 --- /dev/null +++ b/app/Policies/SponsorDomainPolicy.php @@ -0,0 +1,75 @@ +can('view-sponsors'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, SponsorDomain $sponsorDomain): bool + { + return $user->can('view-sponsors'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->can('manage-sponsors'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, SponsorDomain $sponsorDomain): bool + { + return $user->can('manage-sponsors'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, SponsorDomain $sponsorDomain): bool + { + return $user->can('manage-sponsors'); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, SponsorDomain $sponsorDomain): bool + { + return $user->can('manage-sponsors'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, SponsorDomain $sponsorDomain): bool + { + return false; + } + + public function replicate(User $user, SponsorDomain $sponsorDomain): bool + { + return false; + } +} diff --git a/app/Policies/SponsorPolicy.php b/app/Policies/SponsorPolicy.php new file mode 100644 index 000000000..658fb584e --- /dev/null +++ b/app/Policies/SponsorPolicy.php @@ -0,0 +1,75 @@ +can('view-sponsors'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Sponsor $sponsor): bool + { + return $user->can('view-sponsors'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->can('manage-sponsors'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Sponsor $sponsor): bool + { + return $user->can('manage-sponsors'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Sponsor $sponsor): bool + { + return $user->can('manage-sponsors'); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Sponsor $sponsor): bool + { + return $user->can('manage-sponsors'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Sponsor $sponsor): bool + { + return false; + } + + public function replicate(User $user, Sponsor $sponsor): bool + { + return false; + } +} diff --git a/app/Policies/SponsorUserPolicy.php b/app/Policies/SponsorUserPolicy.php new file mode 100644 index 000000000..028f5a66a --- /dev/null +++ b/app/Policies/SponsorUserPolicy.php @@ -0,0 +1,75 @@ +can('view-sponsors'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, SponsorUser $sponsorUser): bool + { + return $user->can('view-sponsors'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return false; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, SponsorUser $sponsorUser): bool + { + return false; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, SponsorUser $sponsorUser): bool + { + return $user->can('manage-sponsors'); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, SponsorUser $sponsorUser): bool + { + return $user->can('manage-sponsors'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, SponsorUser $sponsorUser): bool + { + return false; + } + + public function replicate(User $user, SponsorUser $sponsorUser): bool + { + return false; + } +} diff --git a/app/Rules/SponsorUserValidEmail.php b/app/Rules/SponsorUserValidEmail.php new file mode 100644 index 000000000..e55faf187 --- /dev/null +++ b/app/Rules/SponsorUserValidEmail.php @@ -0,0 +1,48 @@ +input('company'); + + $email = (string) $value; + $domain = substr(strrchr($email, '@'), 1); + + if ($domain === '') { + $fail('Please enter a valid email address.'); + + return; + } + + if ($sponsorId === null || $sponsorId === '') { + $fail('Please select a sponsor before entering an email.'); + + return; + } + + $sponsor = Sponsor::with('domainNames')->find($sponsorId); + if ($sponsor === null) { + $fail('The selected sponsor could not be found.'); + + return; + } + + $exists = $sponsor->domainNames() + ->where('domain_name', $domain) + ->exists(); + + if (! $exists) { + $fail('The email domain "'.$domain.'" is not allowed for '.$sponsor->name.'.'); + } + } +} diff --git a/config/scout.php b/config/scout.php index 759d60692..8d1254546 100644 --- a/config/scout.php +++ b/config/scout.php @@ -233,6 +233,64 @@ 'disableOnNumbers' => true, ], ], + \App\Models\Sponsor::class => [ + 'displayedAttributes' => [ + 'id', + ], + 'searchableAttributes' => [ + 'name', + ], + 'rankingRules' => [ + 'words', + 'typo', + 'proximity', + 'attribute', + 'sort', + 'exactness', + 'end_date_unix:desc', + ], + 'typoTolerance' => [ + 'disableOnNumbers' => true, + ], + ], + \App\Models\SponsorDomain::class => [ + 'displayedAttributes' => [ + 'id', + ], + 'searchableAttributes' => [ + 'domain_name', + ], + 'rankingRules' => [ + 'words', + 'typo', + 'proximity', + 'attribute', + 'sort', + 'exactness', + ], + 'typoTolerance' => [ + 'disableOnNumbers' => true, + ], + ], + \App\Models\SponsorUser::class => [ + 'displayedAttributes' => [ + 'id', + ], + 'searchableAttributes' => [ + 'email', + ], + 'rankingRules' => [ + 'words', + 'typo', + 'proximity', + 'attribute', + 'sort', + 'exactness', + ], + 'typoTolerance' => [ + 'disableOnNumbers' => true, + ], + ], \App\Models\Team::class => [ 'displayedAttributes' => [ 'id', diff --git a/database/migrations/2025_10_21_113837_add_sponsor_permissions_to_officers.php b/database/migrations/2025_10_21_113837_add_sponsor_permissions_to_officers.php new file mode 100644 index 000000000..9b6db2cfd --- /dev/null +++ b/database/migrations/2025_10_21_113837_add_sponsor_permissions_to_officers.php @@ -0,0 +1,40 @@ +forget('spatie.permission.cache'); + + $view = Permission::firstOrCreate(['name' => 'view-sponsors']); + $manage = Permission::firstOrCreate(['name' => 'manage-sponsors']); + + $officer = Role::firstOrCreate(['name' => 'officer']); + $admin = Role::firstOrCreate(['name' => 'admin']); + + $officer->givePermissionTo($manage); + $admin->givePermissionTo($manage); + $officer->givePermissionTo($view); + $admin->givePermissionTo($view); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + app()['cache']->forget('spatie.permission.cache'); + + Permission::where('name', 'view-sponsors')->delete(); + Permission::where('name', 'manage-sponsors')->delete(); + } +};