Add models, enums, and services for user roles, theme preferences, slug history, and ID generation

This commit is contained in:
pacnpal
2025-02-23 19:50:40 -05:00
parent 32aea21e48
commit 7e5d15eb46
55 changed files with 6462 additions and 4 deletions

221
app/Models/Location.php Normal file
View File

@@ -0,0 +1,221 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Location extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<string>
*/
protected $fillable = [
'address',
'city',
'state',
'country',
'postal_code',
'latitude',
'longitude',
'elevation',
'timezone',
'metadata',
'is_approximate',
'source',
'geocoding_data',
'geocoded_at',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'latitude' => 'decimal:8',
'longitude' => 'decimal:8',
'elevation' => 'decimal:2',
'metadata' => 'array',
'geocoding_data' => 'array',
'geocoded_at' => 'datetime',
'is_approximate' => 'boolean',
];
/**
* Get the parent locatable model.
*/
public function locatable(): MorphTo
{
return $this->morphTo();
}
/**
* Get the location's coordinates as an array.
*
* @return array<string, float|null>
*/
public function getCoordinatesAttribute(): array
{
return [
'lat' => $this->latitude,
'lng' => $this->longitude,
];
}
/**
* Get the formatted address.
*
* @return string
*/
public function getFormattedAddressAttribute(): string
{
$parts = [];
if ($this->address) {
$parts[] = $this->address;
}
if ($this->city) {
$parts[] = $this->city;
}
if ($this->state) {
$parts[] = $this->state;
}
if ($this->postal_code) {
$parts[] = $this->postal_code;
}
if ($this->country) {
$parts[] = $this->country;
}
return implode(', ', $parts);
}
/**
* Get Google Maps URL for the location.
*
* @return string|null
*/
public function getMapUrlAttribute(): ?string
{
if (!$this->latitude || !$this->longitude) {
return null;
}
return sprintf(
'https://www.google.com/maps?q=%f,%f',
$this->latitude,
$this->longitude
);
}
/**
* Update the location's coordinates.
*
* @param float $latitude
* @param float $longitude
* @param float|null $elevation
* @return bool
*/
public function updateCoordinates(float $latitude, float $longitude, ?float $elevation = null): bool
{
return $this->update([
'latitude' => $latitude,
'longitude' => $longitude,
'elevation' => $elevation,
]);
}
/**
* Set the location's address components.
*
* @param array<string, string> $components
* @return bool
*/
public function setAddress(array $components): bool
{
return $this->update([
'address' => $components['address'] ?? $this->address,
'city' => $components['city'] ?? $this->city,
'state' => $components['state'] ?? $this->state,
'country' => $components['country'] ?? $this->country,
'postal_code' => $components['postal_code'] ?? $this->postal_code,
]);
}
/**
* Scope a query to find locations within a radius.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param float $latitude
* @param float $longitude
* @param float $radius
* @param string $unit
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeNearby($query, float $latitude, float $longitude, float $radius, string $unit = 'km')
{
$earthRadius = $unit === 'mi' ? 3959 : 6371;
return $query->whereRaw(
"($earthRadius * acos(
cos(radians(?)) *
cos(radians(latitude)) *
cos(radians(longitude) - radians(?)) +
sin(radians(?)) *
sin(radians(latitude))
)) <= ?",
[$latitude, $longitude, $latitude, $radius]
);
}
/**
* Scope a query to find locations within bounds.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param array<string, float> $ne Northeast corner [lat, lng]
* @param array<string, float> $sw Southwest corner [lat, lng]
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeInBounds($query, array $ne, array $sw)
{
return $query->whereBetween('latitude', [$sw['lat'], $ne['lat']])
->whereBetween('longitude', [$sw['lng'], $ne['lng']]);
}
/**
* Calculate the distance to a point.
*
* @param float $latitude
* @param float $longitude
* @param string $unit
* @return float|null
*/
public function distanceTo(float $latitude, float $longitude, string $unit = 'km'): ?float
{
if (!$this->latitude || !$this->longitude) {
return null;
}
$earthRadius = $unit === 'mi' ? 3959 : 6371;
$latFrom = deg2rad($this->latitude);
$lonFrom = deg2rad($this->longitude);
$latTo = deg2rad($latitude);
$lonTo = deg2rad($longitude);
$latDelta = $latTo - $latFrom;
$lonDelta = $lonTo - $lonFrom;
$angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) +
cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2)));
return $angle * $earthRadius;
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Models;
use App\Traits\HasSlugHistory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Manufacturer extends Model
{
use HasFactory, HasSlugHistory;
/**
* The attributes that are mass assignable.
*
* @var array<string>
*/
protected $fillable = [
'name',
'slug',
'website',
'headquarters',
'description',
'total_rides',
'total_roller_coasters',
];
/**
* Get the rides manufactured by this company.
* Note: This relationship will be properly set up when we implement the Rides system.
*/
public function rides(): HasMany
{
return $this->hasMany(Ride::class);
}
/**
* Update ride statistics.
*/
public function updateStatistics(): void
{
$this->total_rides = $this->rides()->count();
$this->total_roller_coasters = $this->rides()
->where('type', 'roller_coaster')
->count();
$this->save();
}
/**
* Get the manufacturer's name with total rides.
*/
public function getDisplayNameAttribute(): string
{
return "{$this->name} ({$this->total_rides} rides)";
}
/**
* Get formatted website URL (ensures proper URL format).
*/
public function getWebsiteUrlAttribute(): string
{
if (!$this->website) {
return '';
}
$website = $this->website;
if (!str_starts_with($website, 'http://') && !str_starts_with($website, 'https://')) {
$website = 'https://' . $website;
}
return $website;
}
/**
* Scope a query to only include major manufacturers (with multiple rides).
*/
public function scopeMajorManufacturers($query, int $minRides = 5)
{
return $query->where('total_rides', '>=', $minRides);
}
/**
* Scope a query to only include coaster manufacturers.
*/
public function scopeCoasterManufacturers($query)
{
return $query->where('total_roller_coasters', '>', 0);
}
/**
* Get the route key for the model.
*/
public function getRouteKeyName(): string
{
return 'slug';
}
}

87
app/Models/Operator.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
namespace App\Models;
use App\Traits\HasSlugHistory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Operator extends Model
{
use HasFactory, HasSlugHistory;
/**
* The attributes that are mass assignable.
*
* @var array<string>
*/
protected $fillable = [
'name',
'slug',
'website',
'headquarters',
'description',
'total_parks',
'total_rides',
];
/**
* Get the parks operated by this company.
*/
public function parks(): HasMany
{
return $this->hasMany(Park::class);
}
/**
* Update park statistics.
*/
public function updateStatistics(): void
{
$this->total_parks = $this->parks()->count();
$this->total_rides = $this->parks()->sum('ride_count');
$this->save();
}
/**
* Get the operator's name with total parks.
*/
public function getDisplayNameAttribute(): string
{
return "{$this->name} ({$this->total_parks} parks)";
}
/**
* Get formatted website URL (ensures proper URL format).
*/
public function getWebsiteUrlAttribute(): string
{
if (!$this->website) {
return '';
}
$website = $this->website;
if (!str_starts_with($website, 'http://') && !str_starts_with($website, 'https://')) {
$website = 'https://' . $website;
}
return $website;
}
/**
* Scope a query to only include major operators (with multiple parks).
*/
public function scopeMajorOperators($query, int $minParks = 3)
{
return $query->where('total_parks', '>=', $minParks);
}
/**
* Get the route key for the model.
*/
public function getRouteKeyName(): string
{
return 'slug';
}
}

182
app/Models/Park.php Normal file
View File

@@ -0,0 +1,182 @@
<?php
namespace App\Models;
use App\Enums\ParkStatus;
use App\Traits\HasSlugHistory;
use App\Traits\HasParkStatistics;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Park extends Model
{
use HasFactory, HasSlugHistory, HasParkStatistics;
/**
* The attributes that are mass assignable.
*
* @var array<string>
*/
protected $fillable = [
'name',
'slug',
'description',
'status',
'opening_date',
'closing_date',
'operating_season',
'size_acres',
'website',
'operator_id',
'total_areas',
'operating_areas',
'closed_areas',
'total_rides',
'total_coasters',
'total_flat_rides',
'total_water_rides',
'total_daily_capacity',
'average_wait_time',
'average_rating',
'total_rides_operated',
'total_rides_retired',
'last_expansion_date',
'last_major_update',
'utilization_rate',
'peak_daily_attendance',
'guest_satisfaction',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'status' => ParkStatus::class,
'opening_date' => 'date',
'closing_date' => 'date',
'size_acres' => 'decimal:2',
'total_areas' => 'integer',
'operating_areas' => 'integer',
'closed_areas' => 'integer',
'total_rides' => 'integer',
'total_coasters' => 'integer',
'total_flat_rides' => 'integer',
'total_water_rides' => 'integer',
'total_daily_capacity' => 'integer',
'average_wait_time' => 'integer',
'average_rating' => 'decimal:2',
'total_rides_operated' => 'integer',
'total_rides_retired' => 'integer',
'last_expansion_date' => 'date',
'last_major_update' => 'date',
'utilization_rate' => 'decimal:2',
'peak_daily_attendance' => 'integer',
'guest_satisfaction' => 'decimal:2',
];
/**
* Get the operator that owns the park.
*/
public function operator(): BelongsTo
{
return $this->belongsTo(Operator::class);
}
/**
* Get the areas in the park.
*/
public function areas(): HasMany
{
return $this->hasMany(ParkArea::class);
}
/**
* Get formatted website URL (ensures proper URL format).
*/
public function getWebsiteUrlAttribute(): string
{
if (!$this->website) {
return '';
}
$website = $this->website;
if (!str_starts_with($website, 'http://') && !str_starts_with($website, 'https://')) {
$website = 'https://' . $website;
}
return $website;
}
/**
* Get the status display classes for Tailwind CSS.
*/
public function getStatusClassesAttribute(): string
{
return $this->status->getStatusClasses();
}
/**
* Get a formatted display of the park's size.
*/
public function getSizeDisplayAttribute(): string
{
return $this->size_acres ? number_format($this->size_acres, 1) . ' acres' : 'Unknown size';
}
/**
* Get the formatted opening year.
*/
public function getOpeningYearAttribute(): ?string
{
return $this->opening_date?->format('Y');
}
/**
* Get a brief description suitable for cards and previews.
*/
public function getBriefDescriptionAttribute(): string
{
$description = $this->description ?? '';
return strlen($description) > 200 ? substr($description, 0, 200) . '...' : $description;
}
/**
* Scope a query to only include operating parks.
*/
public function scopeOperating($query)
{
return $query->where('status', ParkStatus::OPERATING);
}
/**
* Scope a query to only include closed parks.
*/
public function scopeClosed($query)
{
return $query->whereIn('status', [
ParkStatus::CLOSED_TEMP,
ParkStatus::CLOSED_PERM,
ParkStatus::DEMOLISHED,
]);
}
/**
* Boot the model.
*/
protected static function boot()
{
parent::boot();
static::created(function (Park $park) {
$park->operator?->updateStatistics();
});
static::deleted(function (Park $park) {
$park->operator?->updateStatistics();
});
}
}

261
app/Models/ParkArea.php Normal file
View File

@@ -0,0 +1,261 @@
<?php
namespace App\Models;
use App\Traits\HasSlugHistory;
use App\Traits\HasAreaStatistics;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class ParkArea extends Model
{
use HasFactory, HasSlugHistory, HasAreaStatistics;
/**
* The attributes that are mass assignable.
*
* @var array<string>
*/
protected $fillable = [
'name',
'slug',
'description',
'opening_date',
'closing_date',
'position',
'parent_id',
'ride_count',
'coaster_count',
'flat_ride_count',
'water_ride_count',
'daily_capacity',
'peak_wait_time',
'average_rating',
'total_rides_operated',
'retired_rides_count',
'last_new_ride_added',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'opening_date' => 'date',
'closing_date' => 'date',
'position' => 'integer',
'ride_count' => 'integer',
'coaster_count' => 'integer',
'flat_ride_count' => 'integer',
'water_ride_count' => 'integer',
'daily_capacity' => 'integer',
'peak_wait_time' => 'integer',
'average_rating' => 'decimal:2',
'total_rides_operated' => 'integer',
'retired_rides_count' => 'integer',
'last_new_ride_added' => 'date',
];
/**
* Get the park that owns the area.
*/
public function park(): BelongsTo
{
return $this->belongsTo(Park::class);
}
/**
* Get the parent area if this is a sub-area.
*/
public function parent(): BelongsTo
{
return $this->belongsTo(ParkArea::class, 'parent_id');
}
/**
* Get the sub-areas of this area.
*/
public function children(): HasMany
{
return $this->hasMany(ParkArea::class, 'parent_id')
->orderBy('position');
}
/**
* Get a brief description suitable for cards and previews.
*/
public function getBriefDescriptionAttribute(): string
{
$description = $this->description ?? '';
return strlen($description) > 150 ? substr($description, 0, 150) . '...' : $description;
}
/**
* Get the opening year of the area.
*/
public function getOpeningYearAttribute(): ?string
{
return $this->opening_date?->format('Y');
}
/**
* Check if the area is currently operating.
*/
public function isOperating(): bool
{
if ($this->closing_date) {
return false;
}
return true;
}
/**
* Check if this area has sub-areas.
*/
public function hasChildren(): bool
{
return $this->children()->exists();
}
/**
* Check if this is a top-level area.
*/
public function isTopLevel(): bool
{
return is_null($this->parent_id);
}
/**
* Get the next available position for a new area.
*/
public function getNextPosition(): int
{
$maxPosition = static::where('park_id', $this->park_id)
->where('parent_id', $this->parent_id)
->max('position');
return ($maxPosition ?? -1) + 1;
}
/**
* Move this area to a new position.
*/
public function moveToPosition(int $newPosition): void
{
if ($newPosition === $this->position) {
return;
}
$oldPosition = $this->position;
if ($newPosition > $oldPosition) {
// Moving down: decrement positions of items in between
static::where('park_id', $this->park_id)
->where('parent_id', $this->parent_id)
->whereBetween('position', [$oldPosition + 1, $newPosition])
->decrement('position');
} else {
// Moving up: increment positions of items in between
static::where('park_id', $this->park_id)
->where('parent_id', $this->parent_id)
->whereBetween('position', [$newPosition, $oldPosition - 1])
->increment('position');
}
$this->update(['position' => $newPosition]);
}
/**
* Scope query to only include operating areas.
*/
public function scopeOperating($query)
{
return $query->whereNull('closing_date');
}
/**
* Scope query to only include top-level areas.
*/
public function scopeTopLevel($query)
{
return $query->whereNull('parent_id');
}
/**
* Override parent method to ensure unique slugs within a park.
*/
protected function generateSlug(): string
{
$slug = \Str::slug($this->name);
$count = 2;
while (
static::where('park_id', $this->park_id)
->where('slug', $slug)
->where('id', '!=', $this->id)
->exists() ||
static::whereHas('slugHistories', function ($query) use ($slug) {
$query->where('slug', $slug);
})
->where('park_id', $this->park_id)
->where('id', '!=', $this->id)
->exists()
) {
$slug = \Str::slug($this->name) . '-' . $count++;
}
return $slug;
}
/**
* Get the route key for the model.
*/
public function getRouteKeyName(): string
{
return 'slug';
}
/**
* Find an area by its slug within a specific park.
*/
public static function findByParkAndSlug(Park $park, string $slug): ?self
{
// Try current slug
$area = static::where('park_id', $park->id)
->where('slug', $slug)
->first();
if ($area) {
return $area;
}
// Try historical slug
$slugHistory = SlugHistory::where('slug', $slug)
->where('sluggable_type', static::class)
->whereHas('sluggable', function ($query) use ($park) {
$query->where('park_id', $park->id);
})
->latest()
->first();
return $slugHistory ? static::find($slugHistory->sluggable_id) : null;
}
/**
* Boot the model.
*/
protected static function boot()
{
parent::boot();
static::creating(function (ParkArea $area) {
if (is_null($area->position)) {
$area->position = $area->getNextPosition();
}
});
}
}

131
app/Models/Profile.php Normal file
View File

@@ -0,0 +1,131 @@
<?php
namespace App\Models;
use App\Services\IdGenerator;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Facades\Image;
class Profile extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array<string>
*/
protected $fillable = [
'display_name',
'pronouns',
'bio',
'twitter',
'instagram',
'youtube',
'discord',
'coaster_credits',
'dark_ride_credits',
'flat_ride_credits',
'water_ride_credits',
];
/**
* Get the user that owns the profile
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the avatar URL or generate a default one
*/
public function getAvatarUrl(): string
{
if ($this->avatar) {
return Storage::disk('public')->url($this->avatar);
}
// Get first letter of username for default avatar
$firstLetter = strtoupper(substr($this->user->name, 0, 1));
$avatarPath = "avatars/letters/{$firstLetter}_avatar.png";
// Check if letter avatar exists, if not use default
if (Storage::disk('public')->exists($avatarPath)) {
return Storage::disk('public')->url($avatarPath);
}
return asset('images/default-avatar.png');
}
/**
* Set the avatar image
*/
public function setAvatar($file): void
{
if ($this->avatar) {
Storage::disk('public')->delete($this->avatar);
}
$filename = 'avatars/' . uniqid() . '.' . $file->getClientOriginalExtension();
// Process and save the image
$image = Image::make($file)
->fit(200, 200)
->encode();
Storage::disk('public')->put($filename, $image);
$this->update(['avatar' => $filename]);
}
/**
* Generate a letter avatar
*/
protected function generateLetterAvatar(string $letter): void
{
$letter = strtoupper($letter);
$image = Image::canvas(200, 200, '#007bff');
$image->text($letter, 100, 100, function ($font) {
$font->file(public_path('fonts/Roboto-Bold.ttf'));
$font->size(120);
$font->color('#ffffff');
$font->align('center');
$font->valign('center');
});
$filename = "avatars/letters/{$letter}_avatar.png";
Storage::disk('public')->put($filename, $image->encode('png'));
}
/**
* Boot the model
*/
protected static function boot()
{
parent::boot();
static::creating(function (Profile $profile) {
if (!$profile->profile_id) {
$profile->profile_id = IdGenerator::generate(Profile::class, 'profile_id');
}
if (!$profile->display_name) {
$profile->display_name = $profile->user->name;
}
});
static::created(function (Profile $profile) {
// Generate letter avatar if it doesn't exist
$letter = strtoupper(substr($profile->user->name, 0, 1));
$avatarPath = "avatars/letters/{$letter}_avatar.png";
if (!Storage::disk('public')->exists($avatarPath)) {
$profile->generateLetterAvatar($letter);
}
});
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class SlugHistory extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<string>
*/
protected $fillable = [
'slug',
];
/**
* Get the parent sluggable model.
*/
public function sluggable(): MorphTo
{
return $this->morphTo();
}
}

View File

@@ -2,31 +2,36 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\ThemePreference;
use App\Enums\UserRole;
use App\Services\IdGenerator;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
* @var array<string>
*/
protected $fillable = [
'name',
'email',
'password',
'role',
'theme_preference',
'pending_email',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
* @var array<string>
*/
protected $hidden = [
'password',
@@ -43,6 +48,107 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'role' => UserRole::class,
'theme_preference' => ThemePreference::class,
'is_banned' => 'boolean',
'ban_date' => 'datetime',
];
}
/**
* Get the user's profile
*/
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
/**
* Get the user's display name, falling back to username if not set
*/
public function getDisplayName(): string
{
return $this->profile?->display_name ?? $this->name;
}
/**
* Check if the user has moderation privileges
*/
public function canModerate(): bool
{
return $this->role->canModerate();
}
/**
* Check if the user has admin privileges
*/
public function canAdmin(): bool
{
return $this->role->canAdmin();
}
/**
* Ban the user with a reason
*/
public function ban(string $reason): void
{
$this->update([
'is_banned' => true,
'ban_reason' => $reason,
'ban_date' => now(),
]);
}
/**
* Unban the user
*/
public function unban(): void
{
$this->update([
'is_banned' => false,
'ban_reason' => null,
'ban_date' => null,
]);
}
/**
* Handle pending email changes
*/
public function setPendingEmail(string $email): void
{
$this->update(['pending_email' => $email]);
}
/**
* Confirm pending email change
*/
public function confirmEmailChange(): void
{
if ($this->pending_email) {
$this->update([
'email' => $this->pending_email,
'pending_email' => null,
]);
}
}
/**
* Boot the model
*/
protected static function boot()
{
parent::boot();
static::creating(function (User $user) {
if (!$user->user_id) {
$user->user_id = IdGenerator::generate(User::class, 'user_id');
}
if (!$user->role) {
$user->role = UserRole::USER;
}
if (!$user->theme_preference) {
$user->theme_preference = ThemePreference::LIGHT;
}
});
}
}