mirror of
https://github.com/pacnpal/thrillwiki_laravel.git
synced 2025-12-20 08:31:09 -05:00
Add models, enums, and services for user roles, theme preferences, slug history, and ID generation
This commit is contained in:
221
app/Models/Location.php
Normal file
221
app/Models/Location.php
Normal 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;
|
||||
}
|
||||
}
|
||||
98
app/Models/Manufacturer.php
Normal file
98
app/Models/Manufacturer.php
Normal 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
87
app/Models/Operator.php
Normal 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
182
app/Models/Park.php
Normal 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
261
app/Models/ParkArea.php
Normal 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
131
app/Models/Profile.php
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
26
app/Models/SlugHistory.php
Normal file
26
app/Models/SlugHistory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user