diff --git a/CONTEST_ENHANCEMENTS.md b/CONTEST_ENHANCEMENTS.md index 5037b69..9bbab49 100644 --- a/CONTEST_ENHANCEMENTS.md +++ b/CONTEST_ENHANCEMENTS.md @@ -78,9 +78,10 @@ The contest system has been enhanced with the following features: ### Features - Create unlimited categories per contest - Categories can be based on age, gender, or custom criteria -- Users can join multiple categories +- **Automatic assignment**: Users can be automatically assigned to categories based on their profile data (age, gender) +- Users can manually join multiple categories (for non-auto-assign categories) - Separate rankings for each category -- Category membership is optional +- Category membership is optional for manually-joined categories ### Admin Usage @@ -91,23 +92,43 @@ The contest system has been enhanced with the following features: - Fill in: - **Name**: e.g., "Men 18-25", "Women Elite", "Youth" - **Type**: Age, Gender, or Custom (optional) - - **Criteria**: Additional information (optional) + - **Criteria**: For gender type, use: 'male', 'female', or 'other' + - **Auto-assign**: Check this to automatically assign users who match the criteria + - **Minimum Age**: Optional age minimum for automatic assignment + - **Maximum Age**: Optional age maximum for automatic assignment 2. **Manage Categories** - View category participants - Edit category details - Delete categories + - See "Auto-assign" badge for categories with automatic assignment enabled ### User Experience -1. **Joining Categories** +1. **Profile Setup** (Required for auto-assign categories) + - Users can set their birth date and gender in their profile + - Navigate to Profile > Profile Information + - Fill in: + - **Birth Date**: Used to calculate age for category assignment + - **Gender**: Male, Female, or Other + +2. **Automatic Category Assignment** + - When a user logs a route in a contest with auto-assign categories + - The system automatically adds them to matching categories based on: + - Age range (if specified) + - Gender (if specified) + - Users see "Enrolled" badge on auto-assigned categories + - Auto-assigned categories cannot be manually left + +3. **Manual Category Joining** - Visit the contest public page - Switch to "Categories" view mode - Browse available categories - - Click "Join" on categories you want to participate in + - Click "Join" on non-auto-assign categories - You can join multiple categories + - Click "Leave" to exit manually-joined categories -2. **Viewing Category Rankings** +4. **Viewing Category Rankings** - Select a category from the tabs - Rankings show only participants in that category - Rankings are re-calculated specifically for category members @@ -119,7 +140,10 @@ The contest system has been enhanced with the following features: - `contest_id` - Foreign key to contests - `name` - Category name - `type` - Type: 'age', 'gender', or 'custom' -- `criteria` - Additional criteria description +- `criteria` - Additional criteria description (e.g., 'male', 'female') +- `auto_assign` - Boolean: whether to automatically assign users +- `min_age` - Integer: minimum age for automatic assignment (nullable) +- `max_age` - Integer: maximum age for automatic assignment (nullable) - `timestamps` **contest_category_user pivot table:** @@ -127,6 +151,10 @@ The contest system has been enhanced with the following features: - `user_id` - Foreign key to users - Unique constraint on (contest_category_id, user_id) +**users table (new fields):** +- `birth_date` - Date: user's birth date for age calculation (nullable) +- `gender` - String: 'male', 'female', or 'other' (nullable) + ## 3. Route Selection per Contest Step ### Features @@ -232,7 +260,12 @@ The contest system has been enhanced with the following features: - Individual rankings filtered to only include category members - Re-ranked within the category - Same point calculation as individual rankings -- Same point calculation as individual rankings + +**Live View Rankings:** +- The live ranking view automatically adapts based on contest configuration +- If team mode is enabled, displays team rankings by default +- If team mode is disabled, displays individual rankings +- Auto-refreshes every 30 seconds to show current standings ### Step-Specific Routes The ranking logic checks if a step has routes assigned: @@ -248,6 +281,13 @@ $routeIds = $step->routes->count() > 0 - `getTeamRankingForStep($stepId = null)` - Get team rankings - `getCategoryRankings($categoryId, $stepId = null)` - Get category-specific rankings - `getRankingForStep($stepId = null)` - Enhanced to use step-specific routes +- `autoAssignUserToCategories($user)` - Automatically assign user to eligible auto-assign categories + +### ContestCategory Model +- `userMatches($user)` - Check if a user matches category criteria for auto-assignment + +### User Model +- `getAge()` - Calculate user's current age from birth_date ### Team Model - `getTotalPoints()` - Calculate team's total points based on contest's team_points_mode diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 566e51d..ff1156a 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -24,12 +24,16 @@ public function create(array $input): User 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], 'password' => $this->passwordRules(), 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', + 'birth_date' => ['nullable', 'date', 'before:today'], + 'gender' => ['nullable', 'string', 'in:male,female,other'], ])->validate(); return User::create([ 'name' => $input['name'], 'email' => $input['email'], 'password' => Hash::make($input['password']), + 'birth_date' => $input['birth_date'] ?? null, + 'gender' => $input['gender'] ?? null, ]); } } diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index 9738772..e2ab247 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -21,6 +21,8 @@ public function update(User $user, array $input): void 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], + 'birth_date' => ['nullable', 'date', 'before:today'], + 'gender' => ['nullable', 'string', 'in:male,female,other'], ])->validateWithBag('updateProfileInformation'); if (isset($input['photo'])) { @@ -34,6 +36,8 @@ public function update(User $user, array $input): void $user->forceFill([ 'name' => $input['name'], 'email' => $input['email'], + 'birth_date' => $input['birth_date'] ?? null, + 'gender' => $input['gender'] ?? null, ])->save(); } } @@ -49,6 +53,8 @@ protected function updateVerifiedUser(User $user, array $input): void 'name' => $input['name'], 'email' => $input['email'], 'email_verified_at' => null, + 'birth_date' => $input['birth_date'] ?? null, + 'gender' => $input['gender'] ?? null, ])->save(); $user->sendEmailVerificationNotification(); diff --git a/app/Models/Contest.php b/app/Models/Contest.php index d14aa8d..f13fe07 100644 --- a/app/Models/Contest.php +++ b/app/Models/Contest.php @@ -239,4 +239,16 @@ public function getCategoryRankings($categoryId, $stepId = null) return $categoryRankings; } + + public function autoAssignUserToCategories(User $user) + { + $autoAssignCategories = $this->categories()->where('auto_assign', true)->get(); + + foreach ($autoAssignCategories as $category) { + if ($category->userMatches($user)) { + // Sync without detaching to avoid removing existing category memberships + $category->users()->syncWithoutDetaching([$user->id]); + } + } + } } diff --git a/app/Models/ContestCategory.php b/app/Models/ContestCategory.php index cdfa43a..fa44b0b 100644 --- a/app/Models/ContestCategory.php +++ b/app/Models/ContestCategory.php @@ -11,6 +11,13 @@ class ContestCategory extends Model 'contest_id', 'type', 'criteria', + 'auto_assign', + 'min_age', + 'max_age', + ]; + + protected $casts = [ + 'auto_assign' => 'boolean', ]; public function contest() @@ -22,4 +29,36 @@ public function users() { return $this->belongsToMany(User::class, 'contest_category_user')->withTimestamps(); } + + public function userMatches(User $user) + { + if (!$this->auto_assign) { + return false; + } + + // Check gender if type is gender + if ($this->type === 'gender' && $this->criteria) { + if (!$user->gender || strtolower($user->gender) !== strtolower($this->criteria)) { + return false; + } + } + + // Check age if age range is defined + if ($this->min_age !== null || $this->max_age !== null) { + $age = $user->getAge(); + if ($age === null) { + return false; + } + + if ($this->min_age !== null && $age < $this->min_age) { + return false; + } + + if ($this->max_age !== null && $age > $this->max_age) { + return false; + } + } + + return true; + } } diff --git a/app/Models/User.php b/app/Models/User.php index ed69e35..2821f55 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -31,7 +31,9 @@ class User extends Authenticatable 'name', 'email', 'password', - 'google_id' + 'google_id', + 'birth_date', + 'gender', ]; /** @@ -65,6 +67,7 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'birth_date' => 'date', ]; } @@ -118,4 +121,12 @@ public function contestCategories() return $this->belongsToMany(ContestCategory::class, 'contest_category_user')->withTimestamps(); } + public function getAge() + { + if (!$this->birth_date) { + return null; + } + return $this->birth_date->age; + } + } diff --git a/app/Observers/LogObserver.php b/app/Observers/LogObserver.php new file mode 100644 index 0000000..ed07bd3 --- /dev/null +++ b/app/Observers/LogObserver.php @@ -0,0 +1,60 @@ +where('route_id', $log->route_id); + }) + ->where('start_date', '<=', $log->created_at) + ->where('end_date', '>=', $log->created_at) + ->get(); + + // Auto-assign user to eligible categories in these contests + foreach ($contests as $contest) { + $contest->autoAssignUserToCategories($log->user); + } + } + + /** + * Handle the Log "updated" event. + */ + public function updated(Log $log): void + { + // + } + + /** + * Handle the Log "deleted" event. + */ + public function deleted(Log $log): void + { + // + } + + /** + * Handle the Log "restored" event. + */ + public function restored(Log $log): void + { + // + } + + /** + * Handle the Log "force deleted" event. + */ + public function forceDeleted(Log $log): void + { + // + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index beab76a..4073545 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,8 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Gate; +use App\Models\Log; +use App\Observers\LogObserver; class AppServiceProvider extends ServiceProvider @@ -21,6 +23,9 @@ public function register(): void */ public function boot(): void { + // Register observers + Log::observe(LogObserver::class); + // Implicitly grant "Super Admin" role all permissions // This works in the app by using gate-related functions like auth()->user->can() and @can() Gate::before(function ($user, $ability) { diff --git a/database/migrations/2025_10_04_171108_add_age_gender_to_users_table.php b/database/migrations/2025_10_04_171108_add_age_gender_to_users_table.php new file mode 100644 index 0000000..549211c --- /dev/null +++ b/database/migrations/2025_10_04_171108_add_age_gender_to_users_table.php @@ -0,0 +1,29 @@ +date('birth_date')->nullable()->after('google_id'); + $table->string('gender')->nullable()->after('birth_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['birth_date', 'gender']); + }); + } +}; diff --git a/database/migrations/2025_10_04_171129_add_auto_assign_to_contest_categories_table.php b/database/migrations/2025_10_04_171129_add_auto_assign_to_contest_categories_table.php new file mode 100644 index 0000000..82f455e --- /dev/null +++ b/database/migrations/2025_10_04_171129_add_auto_assign_to_contest_categories_table.php @@ -0,0 +1,30 @@ +boolean('auto_assign')->default(false)->after('criteria'); + $table->integer('min_age')->nullable()->after('auto_assign'); + $table->integer('max_age')->nullable()->after('min_age'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('contest_categories', function (Blueprint $table) { + $table->dropColumn(['auto_assign', 'min_age', 'max_age']); + }); + } +}; diff --git a/resources/views/livewire/contests/categories-manager.blade.php b/resources/views/livewire/contests/categories-manager.blade.php index d16fea3..d1ab7cd 100644 --- a/resources/views/livewire/contests/categories-manager.blade.php +++ b/resources/views/livewire/contests/categories-manager.blade.php @@ -18,32 +18,41 @@ #[Validate('nullable|string')] public $criteria = ''; + public $auto_assign = false; + + #[Validate('nullable|integer|min:0')] + public $min_age = null; + + #[Validate('nullable|integer|min:0')] + public $max_age = null; + public $id_editing = 0; public function save() { $this->validate(); + $data = [ + 'name' => $this->name, + 'type' => $this->type, + 'criteria' => $this->criteria, + 'auto_assign' => $this->auto_assign, + 'min_age' => $this->min_age, + 'max_age' => $this->max_age, + ]; + if ($this->id_editing > 0) { $category = ContestCategory::findOrFail($this->id_editing); - $category->update([ - 'name' => $this->name, - 'type' => $this->type, - 'criteria' => $this->criteria, - ]); + $category->update($data); $this->dispatch('action_ok', title: 'Category updated', message: 'Category has been updated successfully!'); } else { - ContestCategory::create([ - 'name' => $this->name, - 'type' => $this->type, - 'criteria' => $this->criteria, - 'contest_id' => $this->contest->id, - ]); + $data['contest_id'] = $this->contest->id; + ContestCategory::create($data); $this->dispatch('action_ok', title: 'Category created', message: 'Category has been created successfully!'); } $this->modal_open = false; - $this->reset(['name', 'type', 'criteria', 'id_editing']); + $this->reset(['name', 'type', 'criteria', 'auto_assign', 'min_age', 'max_age', 'id_editing']); } public function edit($id) @@ -53,6 +62,9 @@ public function edit($id) $this->name = $category->name; $this->type = $category->type; $this->criteria = $category->criteria; + $this->auto_assign = $category->auto_assign; + $this->min_age = $category->min_age; + $this->max_age = $category->max_age; $this->modal_open = true; } @@ -93,10 +105,27 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font {{ ucfirst($category->type) }} @endif + @if($category->auto_assign) + + {{ __('Auto-assign') }} + + @endif @if($category->criteria)
{{ $category->criteria }}
@endif + @if($category->min_age || $category->max_age) ++ {{ __('Age') }}: + @if($category->min_age && $category->max_age) + {{ $category->min_age }}-{{ $category->max_age }} + @elseif($category->min_age) + {{ $category->min_age }}+ + @else + < {{ $category->max_age }} + @endif +
+ @endif{{ $category->users->count() }} {{__('participants')}}
{{ __('Additional information about this category') }}
+{{ __('For gender type, use: male, female, or other') }}
+{{ __('Users will be automatically added to this category when they participate in the contest if they match the criteria') }}
+{{ $category->criteria }}
@endif + @if($category->min_age || $category->max_age) ++ {{ __('Age') }}: + @if($category->min_age && $category->max_age) + {{ $category->min_age }}-{{ $category->max_age }} + @elseif($category->min_age) + {{ $category->min_age }}+ + @else + < {{ $category->max_age }} + @endif +
+ @endif