diff --git a/app/Console/Commands/ImportHubCommand.php b/app/Console/Commands/ImportHubCommand.php index b89bb91..1d92474 100644 --- a/app/Console/Commands/ImportHubCommand.php +++ b/app/Console/Commands/ImportHubCommand.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use App\Jobs\ImportHubDataJob; +use App\Jobs\Import\ImportHubDataJob; use Illuminate\Console\Command; class ImportHubCommand extends Command diff --git a/app/Http/Controllers/ModController.php b/app/Http/Controllers/ModController.php index 22b4922..4016d76 100644 --- a/app/Http/Controllers/ModController.php +++ b/app/Http/Controllers/ModController.php @@ -30,14 +30,12 @@ class ModController extends Controller { $mod = Mod::with([ 'versions', - 'versions.latestSptVersion:id,version,color_class', + 'versions.latestSptVersion', 'versions.latestResolvedDependencies', - 'versions.latestResolvedDependencies.mod:id,name,slug', - 'users:id,name', - 'license:id,name,link', - ]) - ->whereHas('latestVersion') - ->findOrFail($modId); + 'versions.latestResolvedDependencies.mod', + 'license', + 'users', + ])->findOrFail($modId); if ($mod->slug !== $slug) { abort(404); diff --git a/app/Http/Filters/ModFilter.php b/app/Http/Filters/ModFilter.php index b4ca066..733b058 100644 --- a/app/Http/Filters/ModFilter.php +++ b/app/Http/Filters/ModFilter.php @@ -3,7 +3,6 @@ namespace App\Http\Filters; use App\Models\Mod; -use App\Models\ModVersion; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; @@ -17,19 +16,21 @@ class ModFilter protected Builder $builder; /** - * The filter that should be applied to the query. + * The filters to apply. * * @var array */ protected array $filters; /** + * Constructor. + * * @param array $filters */ public function __construct(array $filters) { - $this->builder = $this->baseQuery(); $this->filters = $filters; + $this->builder = $this->baseQuery(); } /** @@ -39,21 +40,31 @@ class ModFilter */ private function baseQuery(): Builder { - return Mod::select([ - 'mods.id', - 'mods.name', - 'mods.slug', - 'mods.teaser', - 'mods.thumbnail', - 'mods.featured', - 'mods.downloads', - 'mods.created_at', - ])->with([ - 'users:id,name', - 'latestVersion' => function ($query) { - $query->with('latestSptVersion:id,version,color_class'); - }, - ]); + return Mod::query() + ->select('mods.*') + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('mod_versions') + ->join('mod_version_spt_version', 'mod_versions.id', '=', 'mod_version_spt_version.mod_version_id') + ->join('spt_versions', 'mod_version_spt_version.spt_version_id', '=', 'spt_versions.id') + ->whereColumn('mod_versions.mod_id', 'mods.id') + ->where('spt_versions.version', '!=', '0.0.0'); + }) + ->with([ + 'users:id,name', + 'latestVersion', + 'latestVersion.latestSptVersion', + ]); + } + + /** + * Filter the results by the given search term. + * + * @return Builder + */ + private function query(string $term): Builder + { + return $this->builder->whereLike('mods.name', "%{$term}%"); } /** @@ -79,36 +90,11 @@ class ModFilter */ private function order(string $type): Builder { - // We order the "recently updated" mods by the ModVersion's updated_at value. - if ($type === 'updated') { - return $this->builder - ->joinSub( - ModVersion::select('mod_id', DB::raw('MAX(updated_at) as latest_updated_at'))->groupBy('mod_id'), - 'latest_versions', - 'mods.id', - '=', - 'latest_versions.mod_id' - ) - ->orderByDesc('latest_versions.latest_updated_at'); - } - - // By default, we simply order by the column on the mods table/query. - $column = match ($type) { - 'downloaded' => 'downloads', - default => 'created_at', + return match ($type) { + 'updated' => $this->builder->orderByDesc('mods.updated_at'), // TODO: This needs to be updated when a version is updated. + 'downloaded' => $this->builder->orderByDesc('mods.downloads'), + default => $this->builder->orderByDesc('mods.created_at'), }; - - return $this->builder->orderByDesc($column); - } - - /** - * Filter the results by the given search term. - * - * @return Builder - */ - private function query(string $term): Builder - { - return $this->builder->whereLike('name', "%$term%"); } /** @@ -119,8 +105,8 @@ class ModFilter private function featured(string $option): Builder { return match ($option) { - 'exclude' => $this->builder->where('featured', false), - 'only' => $this->builder->where('featured', true), + 'exclude' => $this->builder->where('mods.featured', false), + 'only' => $this->builder->where('mods.featured', true), default => $this->builder, }; } @@ -133,28 +119,14 @@ class ModFilter */ private function sptVersions(array $versions): Builder { - // Parse the versions into major, minor, and patch arrays - $parsedVersions = array_map(fn ($version) => [ - 'major' => (int) explode('.', $version)[0], - 'minor' => (int) (explode('.', $version)[1] ?? 0), - 'patch' => (int) (explode('.', $version)[2] ?? 0), - ], $versions); - - [$majorVersions, $minorVersions, $patchVersions] = array_map('array_unique', [ - array_column($parsedVersions, 'major'), - array_column($parsedVersions, 'minor'), - array_column($parsedVersions, 'patch'), - ]); - - return $this->builder - ->join('mod_versions as mv', 'mods.id', '=', 'mv.mod_id') - ->join('mod_version_spt_version as mvsv', 'mv.id', '=', 'mvsv.mod_version_id') - ->join('spt_versions as sv', 'mvsv.spt_version_id', '=', 'sv.id') - ->whereIn('sv.version_major', $majorVersions) - ->whereIn('sv.version_minor', $minorVersions) - ->whereIn('sv.version_patch', $patchVersions) - ->where('sv.version', '!=', '0.0.0') - ->groupBy('mods.id') - ->distinct(); + return $this->builder->whereExists(function ($query) use ($versions) { + $query->select(DB::raw(1)) + ->from('mod_versions') + ->join('mod_version_spt_version', 'mod_versions.id', '=', 'mod_version_spt_version.mod_version_id') + ->join('spt_versions', 'mod_version_spt_version.spt_version_id', '=', 'spt_versions.id') + ->whereColumn('mod_versions.mod_id', 'mods.id') + ->whereIn('spt_versions.version', $versions) + ->where('spt_versions.version', '!=', '0.0.0'); + }); } } diff --git a/app/Http/Filters/V1/ModFilter.php b/app/Http/Filters/V1/ModFilter.php index dd64b11..58a0992 100644 --- a/app/Http/Filters/V1/ModFilter.php +++ b/app/Http/Filters/V1/ModFilter.php @@ -2,9 +2,13 @@ namespace App\Http\Filters\V1; +use App\Models\Mod; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Str; +/** + * @extends QueryFilter + */ class ModFilter extends QueryFilter { protected array $sortable = [ @@ -23,6 +27,11 @@ class ModFilter extends QueryFilter // TODO: Many of these are repeated across UserFilter and ModFilter. Consider refactoring into a shared trait. // Also, consider using common filter types and making the field names dynamic. + /** + * Filter by ID. + * + * @return Builder + */ public function id(string $value): Builder { $ids = array_map('trim', explode(',', $value)); @@ -30,6 +39,11 @@ class ModFilter extends QueryFilter return $this->builder->whereIn('id', $ids); } + /** + * Filter by hub ID. + * + * @return Builder + */ public function hub_id(string $value): Builder { $ids = array_map('trim', explode(',', $value)); @@ -37,6 +51,11 @@ class ModFilter extends QueryFilter return $this->builder->whereIn('hub_id', $ids); } + /** + * Filter by name. + * + * @return Builder + */ public function name(string $value): Builder { // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). @@ -45,6 +64,11 @@ class ModFilter extends QueryFilter return $this->builder->where('name', 'like', $like); } + /** + * Filter by slug. + * + * @return Builder + */ public function slug(string $value): Builder { // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). @@ -53,6 +77,11 @@ class ModFilter extends QueryFilter return $this->builder->where('slug', 'like', $like); } + /** + * Filter by teaser. + * + * @return Builder + */ public function teaser(string $value): Builder { // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). @@ -61,6 +90,11 @@ class ModFilter extends QueryFilter return $this->builder->where('teaser', 'like', $like); } + /** + * Filter by source code link. + * + * @return Builder + */ public function source_code_link(string $value): Builder { // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). @@ -69,6 +103,11 @@ class ModFilter extends QueryFilter return $this->builder->where('source_code_link', 'like', $like); } + /** + * Filter by created at date. + * + * @return Builder + */ public function created_at(string $value): Builder { // The API allows for a range of dates to be passed as a comma-separated list. @@ -80,6 +119,11 @@ class ModFilter extends QueryFilter return $this->builder->whereDate('created_at', $value); } + /** + * Filter by updated at date. + * + * @return Builder + */ public function updated_at(string $value): Builder { // The API allows for a range of dates to be passed as a comma-separated list. @@ -91,6 +135,11 @@ class ModFilter extends QueryFilter return $this->builder->whereDate('updated_at', $value); } + /** + * Filter by published at date. + * + * @return Builder + */ public function published_at(string $value): Builder { // The API allows for a range of dates to be passed as a comma-separated list. @@ -102,6 +151,11 @@ class ModFilter extends QueryFilter return $this->builder->whereDate('published_at', $value); } + /** + * Filter by featured. + * + * @return Builder + */ public function featured(string $value): Builder { // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. @@ -115,6 +169,11 @@ class ModFilter extends QueryFilter return $this->builder->where('featured', $value); } + /** + * Filter by contains ads. + * + * @return Builder + */ public function contains_ads(string $value): Builder { // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. @@ -128,6 +187,11 @@ class ModFilter extends QueryFilter return $this->builder->where('contains_ads', $value); } + /** + * Filter by contains AI content. + * + * @return Builder + */ public function contains_ai_content(string $value): Builder { // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. diff --git a/app/Http/Filters/V1/QueryFilter.php b/app/Http/Filters/V1/QueryFilter.php index f636cd7..3e4c789 100644 --- a/app/Http/Filters/V1/QueryFilter.php +++ b/app/Http/Filters/V1/QueryFilter.php @@ -3,15 +3,25 @@ namespace App\Http\Filters\V1; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Support\Str; +/** + * @template TModelClass of Model + */ abstract class QueryFilter { + /** + * The query builder instance. + * + * @var Builder + */ protected Builder $builder; protected Request $request; + /** @var array */ protected array $sortable = []; public function __construct(Request $request) @@ -19,6 +29,12 @@ abstract class QueryFilter $this->request = $request; } + /** + * Apply the filter to the query builder. + * + * @param Builder $builder + * @return Builder + */ public function apply(Builder $builder): Builder { $this->builder = $builder; @@ -32,17 +48,11 @@ abstract class QueryFilter return $this->builder; } - protected function filter(array $filters): Builder - { - foreach ($filters as $attribute => $value) { - if (method_exists($this, $attribute)) { - $this->$attribute($value); - } - } - - return $this->builder; - } - + /** + * Apply the sort type to the query. + * + * @return Builder + */ protected function sort(string $values): Builder { $sortables = array_map('trim', explode(',', $values)); diff --git a/app/Http/Filters/V1/UserFilter.php b/app/Http/Filters/V1/UserFilter.php index 1eec8fc..f7c5c51 100644 --- a/app/Http/Filters/V1/UserFilter.php +++ b/app/Http/Filters/V1/UserFilter.php @@ -2,11 +2,20 @@ namespace App\Http\Filters\V1; +use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Str; +/** + * @extends QueryFilter + */ class UserFilter extends QueryFilter { + /** + * The sortable fields. + * + * @var array + */ protected array $sortable = [ 'name', 'created_at', @@ -16,6 +25,11 @@ class UserFilter extends QueryFilter // TODO: Many of these are repeated across UserFilter and ModFilter. Consider refactoring into a shared trait. // Also, consider using common filter types and making the field names dynamic. + /** + * Filter by ID. + * + * @return Builder + */ public function id(string $value): Builder { $ids = array_map('trim', explode(',', $value)); @@ -23,6 +37,11 @@ class UserFilter extends QueryFilter return $this->builder->whereIn('id', $ids); } + /** + * Filter by name. + * + * @return Builder + */ public function name(string $value): Builder { // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). @@ -31,6 +50,11 @@ class UserFilter extends QueryFilter return $this->builder->where('name', 'like', $like); } + /** + * Filter by created at date. + * + * @return Builder + */ public function created_at(string $value): Builder { // The API allows for a range of dates to be passed as a comma-separated list. @@ -42,6 +66,11 @@ class UserFilter extends QueryFilter return $this->builder->whereDate('created_at', $value); } + /** + * Filter by updated at date. + * + * @return Builder + */ public function updated_at(string $value): Builder { // The API allows for a range of dates to be passed as a comma-separated list. diff --git a/app/Http/Requests/Api/LoginUserRequest.php b/app/Http/Requests/Api/LoginUserRequest.php index 4ad184e..0fc932a 100644 --- a/app/Http/Requests/Api/LoginUserRequest.php +++ b/app/Http/Requests/Api/LoginUserRequest.php @@ -16,6 +16,8 @@ class LoginUserRequest extends FormRequest /** * Get the validation rules that apply to the request. + * + * @return array */ public function rules(): array { diff --git a/app/Http/Requests/Api/V0/StoreModRequest.php b/app/Http/Requests/Api/V0/StoreModRequest.php index 69bf403..267f46f 100644 --- a/app/Http/Requests/Api/V0/StoreModRequest.php +++ b/app/Http/Requests/Api/V0/StoreModRequest.php @@ -16,11 +16,11 @@ class StoreModRequest extends FormRequest /** * Get the validation rules that apply to the request. + * + * @return array */ public function rules(): array { - return [ - // - ]; + return []; } } diff --git a/app/Http/Requests/Api/V0/StoreUserRequest.php b/app/Http/Requests/Api/V0/StoreUserRequest.php index 590b441..38b3837 100644 --- a/app/Http/Requests/Api/V0/StoreUserRequest.php +++ b/app/Http/Requests/Api/V0/StoreUserRequest.php @@ -16,11 +16,11 @@ class StoreUserRequest extends FormRequest /** * Get the validation rules that apply to the request. + * + * @return array */ public function rules(): array { - return [ - // - ]; + return []; } } diff --git a/app/Http/Requests/Api/V0/UpdateModRequest.php b/app/Http/Requests/Api/V0/UpdateModRequest.php index e4fabca..2193e6b 100644 --- a/app/Http/Requests/Api/V0/UpdateModRequest.php +++ b/app/Http/Requests/Api/V0/UpdateModRequest.php @@ -16,11 +16,11 @@ class UpdateModRequest extends FormRequest /** * Get the validation rules that apply to the request. + * + * @return array */ public function rules(): array { - return [ - // - ]; + return []; } } diff --git a/app/Http/Requests/Api/V0/UpdateUserRequest.php b/app/Http/Requests/Api/V0/UpdateUserRequest.php index 12a1768..d52533e 100644 --- a/app/Http/Requests/Api/V0/UpdateUserRequest.php +++ b/app/Http/Requests/Api/V0/UpdateUserRequest.php @@ -16,11 +16,11 @@ class UpdateUserRequest extends FormRequest /** * Get the validation rules that apply to the request. + * + * @return array */ public function rules(): array { - return [ - // - ]; + return []; } } diff --git a/app/Http/Requests/ModRequest.php b/app/Http/Requests/ModRequest.php index 9f483ad..3899ae6 100644 --- a/app/Http/Requests/ModRequest.php +++ b/app/Http/Requests/ModRequest.php @@ -6,6 +6,11 @@ use Illuminate\Foundation\Http\FormRequest; class ModRequest extends FormRequest { + /** + * Get the validation rules that apply to the request. + * + * @return array + */ public function rules(): array { return [ @@ -18,6 +23,9 @@ class ModRequest extends FormRequest ]; } + /** + * Determine if the user is authorized to make this request. + */ public function authorize(): bool { return true; diff --git a/app/Http/Resources/Api/V0/LicenseResource.php b/app/Http/Resources/Api/V0/LicenseResource.php index a64ebe2..e2e54b2 100644 --- a/app/Http/Resources/Api/V0/LicenseResource.php +++ b/app/Http/Resources/Api/V0/LicenseResource.php @@ -9,6 +9,11 @@ use Illuminate\Http\Resources\Json\JsonResource; /** @mixin License */ class LicenseResource extends JsonResource { + /** + * Transform the resource into an array. + * + * @return array + */ public function toArray(Request $request): array { return [ diff --git a/app/Http/Resources/Api/V0/ModResource.php b/app/Http/Resources/Api/V0/ModResource.php index 23ed9cc..36bcfba 100644 --- a/app/Http/Resources/Api/V0/ModResource.php +++ b/app/Http/Resources/Api/V0/ModResource.php @@ -12,6 +12,8 @@ class ModResource extends JsonResource { /** * Transform the resource into an array. + * + * @return array */ public function toArray(Request $request): array { diff --git a/app/Http/Resources/Api/V0/ModVersionResource.php b/app/Http/Resources/Api/V0/ModVersionResource.php index d42008d..107b21c 100644 --- a/app/Http/Resources/Api/V0/ModVersionResource.php +++ b/app/Http/Resources/Api/V0/ModVersionResource.php @@ -11,6 +11,8 @@ class ModVersionResource extends JsonResource { /** * Transform the resource into an array. + * + * @return array */ public function toArray(Request $request): array { @@ -32,7 +34,6 @@ class ModVersionResource extends JsonResource // downloads that are made, so we'll need a new route/feature for that. #35 'link' => $this->link, - 'spt_version_id' => $this->spt_version_id, 'virus_total_link' => $this->virus_total_link, 'downloads' => $this->downloads, 'created_at' => $this->created_at, @@ -44,7 +45,6 @@ class ModVersionResource extends JsonResource [ 'data' => [ 'type' => 'spt_version', - 'id' => $this->spt_version_id, ], ], ], diff --git a/app/Http/Resources/Api/V0/UserResource.php b/app/Http/Resources/Api/V0/UserResource.php index 903012f..8dfd91f 100644 --- a/app/Http/Resources/Api/V0/UserResource.php +++ b/app/Http/Resources/Api/V0/UserResource.php @@ -10,6 +10,11 @@ use Illuminate\Http\Resources\Json\JsonResource; /** @mixin User */ class UserResource extends JsonResource { + /** + * Transform the resource into an array. + * + * @return array + */ public function toArray(Request $request): array { return [ diff --git a/app/Http/Resources/Api/V0/UserRoleResource.php b/app/Http/Resources/Api/V0/UserRoleResource.php index 51d5146..4d16ad6 100644 --- a/app/Http/Resources/Api/V0/UserRoleResource.php +++ b/app/Http/Resources/Api/V0/UserRoleResource.php @@ -9,6 +9,11 @@ use Illuminate\Http\Resources\Json\JsonResource; /** @mixin UserRole */ class UserRoleResource extends JsonResource { + /** + * Transform the resource into an array. + * + * @return array + */ public function toArray(Request $request): array { return [ diff --git a/app/Http/Resources/LicenseResource.php b/app/Http/Resources/LicenseResource.php index ccc580b..46d9f43 100644 --- a/app/Http/Resources/LicenseResource.php +++ b/app/Http/Resources/LicenseResource.php @@ -2,12 +2,18 @@ namespace App\Http\Resources; +use App\Models\License; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; -/** @mixin \App\Models\License */ +/** @mixin License */ class LicenseResource extends JsonResource { + /** + * Transform the resource into an array. + * + * @return array + */ public function toArray(Request $request): array { return [ diff --git a/app/Http/Resources/ModResource.php b/app/Http/Resources/ModResource.php index c869ee5..db56e51 100644 --- a/app/Http/Resources/ModResource.php +++ b/app/Http/Resources/ModResource.php @@ -2,12 +2,18 @@ namespace App\Http\Resources; +use App\Models\Mod; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; -/** @mixin \App\Models\Mod */ +/** @mixin Mod */ class ModResource extends JsonResource { + /** + * Transform the resource into an array. + * + * @return array + */ public function toArray(Request $request): array { return [ diff --git a/app/Http/Resources/ModVersionResource.php b/app/Http/Resources/ModVersionResource.php index 1f4d045..1878b8b 100644 --- a/app/Http/Resources/ModVersionResource.php +++ b/app/Http/Resources/ModVersionResource.php @@ -2,12 +2,18 @@ namespace App\Http\Resources; +use App\Models\ModVersion; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; -/** @mixin \App\Models\ModVersion */ +/** @mixin ModVersion */ class ModVersionResource extends JsonResource { + /** + * Transform the resource into an array. + * + * @return array + */ public function toArray(Request $request): array { return [ @@ -19,10 +25,7 @@ class ModVersionResource extends JsonResource 'description' => $this->description, 'virus_total_link' => $this->virus_total_link, 'downloads' => $this->downloads, - 'mod_id' => $this->mod_id, - 'spt_version_id' => $this->spt_version_id, - 'mod' => new ModResource($this->whenLoaded('mod')), 'sptVersion' => new SptVersionResource($this->whenLoaded('sptVersion')), ]; diff --git a/app/Http/Resources/SptVersionResource.php b/app/Http/Resources/SptVersionResource.php index 49ecf3a..58d3d62 100644 --- a/app/Http/Resources/SptVersionResource.php +++ b/app/Http/Resources/SptVersionResource.php @@ -2,12 +2,18 @@ namespace App\Http\Resources; +use App\Models\SptVersion; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; -/** @mixin \App\Models\SptVersion */ +/** @mixin SptVersion */ class SptVersionResource extends JsonResource { + /** + * Transform the resource into an array. + * + * @return array + */ public function toArray(Request $request): array { return [ diff --git a/app/Jobs/Import/DataTransferObjects/HubUser.php b/app/Jobs/Import/DataTransferObjects/HubUser.php new file mode 100644 index 0000000..946b1ee --- /dev/null +++ b/app/Jobs/Import/DataTransferObjects/HubUser.php @@ -0,0 +1,23 @@ +collectUserData($curl, $user); + $hubUser = new HubUser( + $user->userID, + $user->username, + $user->email, + $user->password, + $user->registrationDate, + $user->banned, + $user->banReason, + $user->banExpires, + $user->coverPhotoHash, + $user->coverPhotoExtension, + $user->rankID, + $user->rankTitle + ); - $bannedUserData = $this->collectBannedUserData($user); + $userData[] = $this->collectUserData($curl, $hubUser); + + $bannedUserData = $this->collectBannedUserData($hubUser); if ($bannedUserData) { $bannedUsers[] = $bannedUserData; } - $userRankData = $this->collectUserRankData($user); + $userRankData = $this->collectUserRankData($hubUser); if ($userRankData) { $userRanks[] = $userRankData; } @@ -328,16 +346,21 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue curl_close($curl); } - protected function collectUserData(CurlHandle $curl, object $user): array + /** + * Build an array of user data ready to be inserted into the local database. + * + * @return array + */ + protected function collectUserData(CurlHandle $curl, HubUser $hubUser): array { return [ - 'hub_id' => (int) $user->userID, - 'name' => $user->username, - 'email' => Str::lower($user->email), - 'password' => $this->cleanPasswordHash($user->password), - 'profile_photo_path' => $this->fetchUserAvatar($curl, $user), - 'cover_photo_path' => $this->fetchUserCoverPhoto($curl, $user), - 'created_at' => $this->cleanRegistrationDate($user->registrationDate), + 'hub_id' => (int) $hubUser->userID, + 'name' => $hubUser->username, + 'email' => Str::lower($hubUser->email), + 'password' => $this->cleanPasswordHash($hubUser->password), + 'profile_photo_path' => $this->fetchUserAvatar($curl, $hubUser), + 'cover_photo_path' => $this->fetchUserCoverPhoto($curl, $hubUser), + 'created_at' => $this->cleanRegistrationDate($hubUser->registrationDate), 'updated_at' => now('UTC')->toDateTimeString(), ]; } @@ -358,10 +381,10 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue /** * Fetch the user avatar from the Hub and store it anew. */ - protected function fetchUserAvatar(CurlHandle $curl, object $user): string + protected function fetchUserAvatar(CurlHandle $curl, HubUser $hubUser): string { // Fetch the user's avatar data from the temporary table. - $avatar = DB::table('temp_user_avatar')->where('userID', $user->userID)->first(); + $avatar = DB::table('temp_user_avatar')->where('userID', $hubUser->userID)->first(); if (! $avatar) { return ''; @@ -410,15 +433,15 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue /** * Fetch the user avatar from the Hub and store it anew. */ - protected function fetchUserCoverPhoto(CurlHandle $curl, object $user): string + protected function fetchUserCoverPhoto(CurlHandle $curl, HubUser $hubUser): string { - if (empty($user->coverPhotoHash) || empty($user->coverPhotoExtension)) { + if (empty($hubUser->coverPhotoHash) || empty($hubUser->coverPhotoExtension)) { return ''; } - $hashShort = substr($user->coverPhotoHash, 0, 2); - $fileName = $user->coverPhotoHash.'.'.$user->coverPhotoExtension; - $hubUrl = 'https://hub.sp-tarkov.com/images/coverPhotos/'.$hashShort.'/'.$user->userID.'-'.$fileName; + $hashShort = substr($hubUser->coverPhotoHash, 0, 2); + $fileName = $hubUser->coverPhotoHash.'.'.$hubUser->coverPhotoExtension; + $hubUrl = 'https://hub.sp-tarkov.com/images/coverPhotos/'.$hashShort.'/'.$hubUser->userID.'-'.$fileName; $relativePath = 'user-covers/'.$fileName; return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath); @@ -441,14 +464,16 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue /** * Build an array of banned user data ready to be inserted into the local database. + * + * @return array|null */ - protected function collectBannedUserData($user): ?array + protected function collectBannedUserData(HubUser $hubUser): ?array { - if ($user->banned) { + if ($hubUser->banned) { return [ - 'hub_id' => (int) $user->userID, - 'comment' => $user->banReason ?? '', - 'expired_at' => $this->cleanUnbannedAtDate($user->banExpires), + 'hub_id' => (int) $hubUser->userID, + 'comment' => $hubUser->banReason ?? '', + 'expired_at' => $this->cleanUnbannedAtDate($hubUser->banExpires), ]; } @@ -495,12 +520,17 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue } } - protected function collectUserRankData($user): ?array + /** + * Build an array of user rank data ready to be inserted into the local database. + * + * @return array|null + */ + protected function collectUserRankData(HubUser $hubUser): ?array { - if ($user->rankID && $user->rankTitle) { + if ($hubUser->rankID && $hubUser->rankTitle) { return [ - 'hub_id' => (int) $user->userID, - 'title' => $user->rankTitle, + 'hub_id' => (int) $hubUser->userID, + 'title' => $hubUser->rankTitle, ]; } @@ -509,8 +539,10 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue /** * Insert or update the users in the local database. + * + * @param array> $usersData */ - protected function upsertUsers($usersData): void + protected function upsertUsers(array $usersData): void { if (! empty($usersData)) { DB::table('users')->upsert($usersData, ['hub_id'], [ @@ -525,8 +557,10 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue /** * Fetch the hub-banned users from the local database and ban them locally. + * + * @param array> $bannedUsers */ - protected function handleBannedUsers($bannedUsers): void + protected function handleBannedUsers(array $bannedUsers): void { foreach ($bannedUsers as $bannedUser) { $user = User::whereHubId($bannedUser['hub_id'])->first(); @@ -539,8 +573,10 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue /** * Fetch or create the user ranks in the local database and assign them to the users. + * + * @param array> $userRanks */ - protected function handleUserRoles($userRanks): void + protected function handleUserRoles(array $userRanks): void { foreach ($userRanks as $userRank) { $roleName = Str::ucfirst(Str::afterLast($userRank['title'], '.')); @@ -555,6 +591,8 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue /** * Build the user role data based on the role name. + * + * @return array */ protected function buildUserRoleData(string $name): array { @@ -672,6 +710,8 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue /** * Get the latest current version from the response data. + * + * @param array> $versions */ protected function getLatestVersion(array $versions): string { @@ -931,10 +971,20 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue $sptVersionTemp = DB::table('temp_spt_version_tags')->where('hub_id', $versionLabel->labelID)->value('version'); $sptVersionConstraint = $this->extractSemanticVersion($sptVersionTemp, appendPatch: true) ?? '0.0.0'; + try { + $modVersion = new Version($version->versionNumber); + } catch (InvalidVersionNumberException $e) { + $modVersion = new Version('0.0.0'); + } + $insertData[] = [ 'hub_id' => (int) $version->versionID, 'mod_id' => $modId, - 'version' => $this->extractSemanticVersion($version->versionNumber) ?? '0.0.0', + 'version' => $modVersion, + 'version_major' => $modVersion->getMajor(), + 'version_minor' => $modVersion->getMinor(), + 'version_patch' => $modVersion->getPatch(), + 'version_pre_release' => $modVersion->getPreRelease(), 'description' => $this->cleanHubContent($versionContent->description ?? ''), 'link' => $version->downloadURL, 'spt_version_constraint' => $sptVersionConstraint, diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 991f5d1..e8dfd39 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -4,6 +4,7 @@ namespace App\Livewire; use App\Models\Mod; use App\Models\User; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\View\View; use Livewire\Component; @@ -34,16 +35,18 @@ class GlobalSearch extends Component /** * Execute the search against each of the searchable models. + * + * @return array>> */ protected function executeSearch(string $query): array { $query = Str::trim($query); $results = ['data' => [], 'total' => 0]; - if (Str::length($query)) { + if (Str::length($query) > 0) { $results['data'] = [ - 'user' => collect(User::search($query)->raw()['hits']), - 'mod' => collect(Mod::search($query)->raw()['hits']), + 'user' => $this->fetchUserResults($query), + 'mod' => $this->fetchModResults($query), ]; $results['total'] = $this->countTotalResults($results['data']); } @@ -55,11 +58,39 @@ class GlobalSearch extends Component } /** - * Count the total number of results across all models. + * Fetch the user search results. + * + * @return Collection> */ - protected function countTotalResults($results): int + protected function fetchUserResults(string $query): Collection { - return collect($results)->reduce(function ($carry, $result) { + /** @var array> $userHits */ + $userHits = User::search($query)->raw()['hits']; + + return collect($userHits); + } + + /** + * Fetch the mod search results. + * + * @return Collection> + */ + protected function fetchModResults(string $query): Collection + { + /** @var array> $modHits */ + $modHits = Mod::search($query)->raw()['hits']; + + return collect($modHits); + } + + /** + * Count the total number of results across all models. + * + * @param array>> $results + */ + protected function countTotalResults(array $results): int + { + return collect($results)->reduce(function (int $carry, Collection $result) { return $carry + $result->count(); }, 0); } diff --git a/app/Livewire/Mod/Index.php b/app/Livewire/Mod/Listing.php similarity index 82% rename from app/Livewire/Mod/Index.php rename to app/Livewire/Mod/Listing.php index 69cabb4..1edf8dd 100644 --- a/app/Livewire/Mod/Index.php +++ b/app/Livewire/Mod/Listing.php @@ -3,7 +3,9 @@ namespace App\Livewire\Mod; use App\Http\Filters\ModFilter; +use App\Models\Mod; use App\Models\SptVersion; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Cache; @@ -12,7 +14,7 @@ use Livewire\Attributes\Url; use Livewire\Component; use Livewire\WithPagination; -class Index extends Component +class Listing extends Component { use WithPagination; @@ -30,6 +32,8 @@ class Index extends Component /** * The SPT versions filter value. + * + * @var array */ #[Url] public array $sptVersions = []; @@ -42,6 +46,8 @@ class Index extends Component /** * The available SPT versions. + * + * @var Collection */ public Collection $activeSptVersions; @@ -59,6 +65,8 @@ class Index extends Component /** * Get all patch versions of the latest minor SPT version. + * + * @return Collection */ public function getLatestMinorVersions(): Collection { @@ -81,12 +89,21 @@ class Index extends Component ]; $mods = (new ModFilter($filters))->apply()->paginate(16); - // Check if the current page is greater than the last page. Redirect if it is. + $this->redirectOutOfBoundsPage($mods); + + return view('livewire.mod.listing', compact('mods')); + } + + /** + * Check if the current page is greater than the last page. Redirect if it is. + * + * @param LengthAwarePaginator $mods + */ + private function redirectOutOfBoundsPage(LengthAwarePaginator $mods): void + { if ($mods->currentPage() > $mods->lastPage()) { $this->redirectRoute('mods', ['page' => $mods->lastPage()]); } - - return view('livewire.mod.index', compact('mods')); } /** diff --git a/app/Models/License.php b/app/Models/License.php index 7c3118b..00533bc 100644 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -2,6 +2,7 @@ namespace App\Models; +use Database\Factories\LicenseFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -9,10 +10,15 @@ use Illuminate\Database\Eloquent\SoftDeletes; class License extends Model { - use HasFactory, SoftDeletes; + /** @use HasFactory */ + use HasFactory; + + use SoftDeletes; /** * The relationship between a license and mod. + * + * @return HasMany */ public function mods(): HasMany { diff --git a/app/Models/Mod.php b/app/Models/Mod.php index 7d81d9d..75755bc 100644 --- a/app/Models/Mod.php +++ b/app/Models/Mod.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Http\Filters\V1\QueryFilter; use App\Models\Scopes\DisabledScope; use App\Models\Scopes\PublishedScope; +use Database\Factories\ModFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -19,24 +20,20 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Laravel\Scout\Searchable; -/** - * @property int $id - * @property string $name - * @property string $slug - */ class Mod extends Model { - use HasFactory, Searchable, SoftDeletes; + /** @use HasFactory */ + use HasFactory; + + use Searchable; + use SoftDeletes; /** * Post boot method to configure the model. */ protected static function booted(): void { - // Apply the global scope to exclude disabled mods. static::addGlobalScope(new DisabledScope); - - // Apply the global scope to exclude non-published mods. static::addGlobalScope(new PublishedScope); } @@ -51,6 +48,8 @@ class Mod extends Model /** * The relationship between a mod and its users. + * + * @return BelongsToMany */ public function users(): BelongsToMany { @@ -59,6 +58,8 @@ class Mod extends Model /** * The relationship between a mod and its license. + * + * @return BelongsTo */ public function license(): BelongsTo { @@ -66,29 +67,37 @@ class Mod extends Model } /** - * The relationship between a mod and its versions. + * The relationship between a mod and its last updated version. + * + * @return HasOne */ - public function versions(): HasMany + public function latestUpdatedVersion(): HasOne { - return $this->hasMany(ModVersion::class) - ->whereHas('latestSptVersion') - ->orderByDesc('version') + return $this->versions() + ->one() + ->ofMany('updated_at', 'max') ->chaperone(); } /** - * The relationship between a mod and its last updated version. + * The relationship between a mod and its versions. + * + * @return HasMany */ - public function lastUpdatedVersion(): HasOne + public function versions(): HasMany { - return $this->hasOne(ModVersion::class) - ->whereHas('latestSptVersion') - ->orderByDesc('updated_at') + return $this->hasMany(ModVersion::class) + ->orderByDesc('version_major') + ->orderByDesc('version_minor') + ->orderByDesc('version_patch') + ->orderByDesc('version_pre_release') ->chaperone(); } /** * The data that is searchable by Scout. + * + * @return array */ public function toSearchableArray(): array { @@ -102,24 +111,11 @@ class Mod extends Model 'created_at' => strtotime($this->created_at), 'updated_at' => strtotime($this->updated_at), 'published_at' => strtotime($this->published_at), - 'latestVersion' => $this->latestVersion()?->first()?->latestSptVersion()?->first()?->version_formatted, - 'latestVersionColorClass' => $this->latestVersion()?->first()?->latestSptVersion()?->first()?->color_class, + 'latestVersion' => $this->latestVersion->latestSptVersion->version_formatted, + 'latestVersionColorClass' => $this->latestVersion->latestSptVersion->color_class, ]; } - /** - * The relationship to the latest mod version, dictated by the mod version number. - */ - public function latestVersion(): HasOne - { - return $this->hasOne(ModVersion::class) - ->whereHas('sptVersions') - ->orderByDesc('version') - ->orderByDesc('updated_at') - ->take(1) - ->chaperone(); - } - /** * Determine if the model instance should be searchable. */ @@ -135,16 +131,13 @@ class Mod extends Model return false; } - // Fetch the latest version instance. - $latestVersion = $this->latestVersion()?->first(); - // Ensure the mod has a latest version. - if (is_null($latestVersion)) { + if ($this->latestVersion()->doesntExist()) { return false; } // Ensure the latest version has a latest SPT version. - if ($latestVersion->latestSptVersion()->doesntExist()) { + if ($this->latestVersion->latestSptVersion()->doesntExist()) { return false; } @@ -152,7 +145,7 @@ class Mod extends Model $activeSptVersions = Cache::remember('active-spt-versions', 60 * 60, function () { return SptVersion::getVersionsForLastThreeMinors(); }); - if (! in_array($latestVersion->latestSptVersion()->first()->version, $activeSptVersions->pluck('version')->toArray())) { + if (! in_array($this->latestVersion->latestSptVersion->version, $activeSptVersions->pluck('version')->toArray())) { return false; } @@ -160,8 +153,28 @@ class Mod extends Model return true; } + /** + * The relationship between a mod and its latest version. + * + * @return HasOne + */ + public function latestVersion(string $sort = 'version'): HasOne + { + return $this->versions() + ->one() + ->ofMany([ + 'version_major' => 'max', + 'version_minor' => 'max', + 'version_patch' => 'max', + 'version_pre_release' => 'max', + ]) + ->chaperone(); + } + /** * Build the URL to the mod's thumbnail. + * + * @return Attribute */ public function thumbnailUrl(): Attribute { @@ -185,6 +198,10 @@ class Mod extends Model /** * Scope a query by applying QueryFilter filters. + * + * @param Builder $builder + * @param QueryFilter $filters + * @return Builder */ public function scopeFilter(Builder $builder, QueryFilter $filters): Builder { @@ -201,6 +218,8 @@ class Mod extends Model /** * The attributes that should be cast to native types. + * + * @return array */ protected function casts(): array { @@ -209,11 +228,16 @@ class Mod extends Model 'contains_ai_content' => 'boolean', 'contains_ads' => 'boolean', 'disabled' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', ]; } /** * Mutate the slug attribute to always be lower case on get and slugified on set. + * + * @return Attribute */ protected function slug(): Attribute { diff --git a/app/Models/ModDependency.php b/app/Models/ModDependency.php index 1a0ec75..1a65b71 100644 --- a/app/Models/ModDependency.php +++ b/app/Models/ModDependency.php @@ -2,24 +2,21 @@ namespace App\Models; +use Database\Factories\ModDependencyFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -/** - * @property int $id - * @property int $mod_version_id - * @property int $dependency_mod_id - * @property string $constraint - * @property int|null $resolved_version_id - */ class ModDependency extends Model { + /** @use HasFactory */ use HasFactory; /** * The relationship between the mod dependency and the mod version. + * + * @return BelongsTo */ public function modVersion(): BelongsTo { @@ -28,6 +25,8 @@ class ModDependency extends Model /** * The relationship between the mod dependency and the resolved dependency. + * + * @return HasMany */ public function resolvedDependencies(): HasMany { @@ -37,6 +36,8 @@ class ModDependency extends Model /** * The relationship between the mod dependency and the dependent mod. + * + * @return BelongsTo */ public function dependentMod(): BelongsTo { diff --git a/app/Models/ModResolvedDependency.php b/app/Models/ModResolvedDependency.php index ee6e747..41d47c8 100644 --- a/app/Models/ModResolvedDependency.php +++ b/app/Models/ModResolvedDependency.php @@ -9,6 +9,8 @@ class ModResolvedDependency extends Model { /** * The relationship between the resolved dependency and the mod version. + * + * @return BelongsTo */ public function modVersion(): BelongsTo { @@ -17,6 +19,8 @@ class ModResolvedDependency extends Model /** * The relationship between the resolved dependency and the dependency. + * + * @return BelongsTo */ public function dependency(): BelongsTo { @@ -25,6 +29,8 @@ class ModResolvedDependency extends Model /** * The relationship between the resolved dependency and the resolved mod version. + * + * @return BelongsTo */ public function resolvedModVersion(): BelongsTo { diff --git a/app/Models/ModVersion.php b/app/Models/ModVersion.php index 3221173..6da4072 100644 --- a/app/Models/ModVersion.php +++ b/app/Models/ModVersion.php @@ -2,23 +2,25 @@ namespace App\Models; +use App\Exceptions\InvalidVersionNumberException; use App\Models\Scopes\DisabledScope; use App\Models\Scopes\PublishedScope; +use App\Support\Version; +use Database\Factories\ModFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Database\Eloquent\SoftDeletes; -/** - * @property int $id - * @property int $mod_id - * @property string $version - */ class ModVersion extends Model { - use HasFactory, SoftDeletes; + /** @use HasFactory */ + use HasFactory; + + use SoftDeletes; /** * Post boot method to configure the model. @@ -26,11 +28,31 @@ class ModVersion extends Model protected static function booted(): void { static::addGlobalScope(new DisabledScope); + static::addGlobalScope(new PublishedScope); + + static::saving(function (ModVersion $model) { + // Extract the version sections from the version string. + try { + $version = new Version($model->version); + + $model->version_major = $version->getMajor(); + $model->version_minor = $version->getMinor(); + $model->version_patch = $version->getPatch(); + $model->version_pre_release = $version->getPreRelease(); + } catch (InvalidVersionNumberException $e) { + $model->version_major = 0; + $model->version_minor = 0; + $model->version_patch = 0; + $model->version_pre_release = ''; + } + }); } /** * The relationship between a mod version and mod. + * + * @return BelongsTo */ public function mod(): BelongsTo { @@ -39,6 +61,8 @@ class ModVersion extends Model /** * The relationship between a mod version and its dependencies. + * + * @return HasMany */ public function dependencies(): HasMany { @@ -48,6 +72,8 @@ class ModVersion extends Model /** * The relationship between a mod version and its resolved dependencies. + * + * @return BelongsToMany */ public function resolvedDependencies(): BelongsToMany { @@ -58,6 +84,8 @@ class ModVersion extends Model /** * The relationship between a mod version and its each of it's resolved dependencies' latest versions. + * + * @return BelongsToMany */ public function latestResolvedDependencies(): BelongsToMany { @@ -72,22 +100,32 @@ class ModVersion extends Model } /** - * The relationship between a mod version and each of its SPT versions' latest version. - * Hint: Be sure to call `->first()` on this to get the actual instance. + * The relationship between a mod version and its latest SPT version. + * + * @return HasOneThrough */ - public function latestSptVersion(): BelongsToMany + public function latestSptVersion(): HasOneThrough { - return $this->belongsToMany(SptVersion::class, 'mod_version_spt_version') - ->orderBy('version', 'desc') + return $this->hasOneThrough(SptVersion::class, ModVersionSptVersion::class, 'mod_version_id', 'id', 'id', 'spt_version_id') + ->orderByDesc('spt_versions.version_major') + ->orderByDesc('spt_versions.version_minor') + ->orderByDesc('spt_versions.version_patch') + ->orderByDesc('spt_versions.version_pre_release') ->limit(1); } /** * The relationship between a mod version and its SPT versions. + * + * @return BelongsToMany */ public function sptVersions(): BelongsToMany { - return $this->belongsToMany(SptVersion::class, 'mod_version_spt_version') - ->orderByDesc('version'); + return $this->belongsToMany(SptVersion::class) + ->using(ModVersionSptVersion::class) + ->orderByDesc('version_major') + ->orderByDesc('version_minor') + ->orderByDesc('version_patch') + ->orderByDesc('version_pre_release'); } } diff --git a/app/Models/ModVersionSptVersion.php b/app/Models/ModVersionSptVersion.php new file mode 100644 index 0000000..355dce7 --- /dev/null +++ b/app/Models/ModVersionSptVersion.php @@ -0,0 +1,10 @@ + $builder */ public function apply(Builder $builder, Model $model): void { diff --git a/app/Models/Scopes/PublishedScope.php b/app/Models/Scopes/PublishedScope.php index a4576ed..ac8a2a9 100644 --- a/app/Models/Scopes/PublishedScope.php +++ b/app/Models/Scopes/PublishedScope.php @@ -6,10 +6,15 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; +/** + * @template TModelClass of Model + */ class PublishedScope implements Scope { /** * Apply the scope to a given Eloquent query builder. + * + * @param Builder $builder */ public function apply(Builder $builder, Model $model): void { diff --git a/app/Models/SptVersion.php b/app/Models/SptVersion.php index 9d0aa0f..4649434 100644 --- a/app/Models/SptVersion.php +++ b/app/Models/SptVersion.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Exceptions\InvalidVersionNumberException; +use App\Support\Version; +use Database\Factories\SptVersionFactory; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -12,10 +14,15 @@ use Illuminate\Support\Facades\Cache; class SptVersion extends Model { - use HasFactory, SoftDeletes; + /** @use HasFactory */ + use HasFactory; + + use SoftDeletes; /** * Get all versions for the last three minor versions. + * + * @return Collection */ public static function getVersionsForLastThreeMinors(): Collection { @@ -44,6 +51,8 @@ class SptVersion extends Model /** * Get the last three minor versions (major.minor format). + * + * @return array */ public static function getLastThreeMinorVersions(): array { @@ -54,7 +63,7 @@ class SptVersion extends Model ->orderByDesc('version_minor') ->limit(3) ->get() - ->map(function ($version) { + ->map(function (SptVersion $version) { return [ 'major' => (int) $version->version_major, 'minor' => (int) $version->version_minor, @@ -63,38 +72,11 @@ class SptVersion extends Model ->toArray(); } - /** - * Called when the model is booted. - */ - protected static function booted(): void - { - // Callback that runs before saving the model. - static::saving(function ($model) { - // Extract the version sections from the version string. - if (! empty($model->version)) { - // Default values in case there's an exception. - $model->version_major = 0; - $model->version_minor = 0; - $model->version_patch = 0; - $model->version_pre_release = ''; - - try { - $versionSections = self::extractVersionSections($model->version); - } catch (InvalidVersionNumberException $e) { - return; - } - - $model->version_major = $versionSections['major']; - $model->version_minor = $versionSections['minor']; - $model->version_patch = $versionSections['patch']; - $model->version_pre_release = $versionSections['pre_release']; - } - }); - } - /** * Extract the version sections from the version string. * + * @return array{major: int, minor: int, patch: int, pre_release: string} + * * @throws InvalidVersionNumberException */ public static function extractVersionSections(string $version): array @@ -116,6 +98,29 @@ class SptVersion extends Model ]; } + /** + * Called when the model is booted. + */ + protected static function booted(): void + { + static::saving(function (SptVersion $model) { + // Extract the version sections from the version string. + try { + $version = new Version($model->version); + + $model->version_major = $version->getMajor(); + $model->version_minor = $version->getMinor(); + $model->version_patch = $version->getPatch(); + $model->version_pre_release = $version->getPreRelease(); + } catch (InvalidVersionNumberException $e) { + $model->version_major = 0; + $model->version_minor = 0; + $model->version_patch = 0; + $model->version_pre_release = ''; + } + }); + } + /** * Update the mod count for this SptVersion. */ @@ -131,10 +136,13 @@ class SptVersion extends Model /** * The relationship between an SPT version and mod version. + * + * @return BelongsToMany */ public function modVersions(): BelongsToMany { - return $this->belongsToMany(ModVersion::class, 'mod_version_spt_version'); + return $this->belongsToMany(ModVersion::class) + ->using(ModVersionSptVersion::class); } /** diff --git a/app/Models/User.php b/app/Models/User.php index 35d6fc3..7b3088b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,6 +6,7 @@ use App\Http\Filters\V1\QueryFilter; use App\Notifications\ResetPassword; use App\Notifications\VerifyEmail; use App\Traits\HasCoverPhoto; +use Database\Factories\UserFactory; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -25,7 +26,10 @@ class User extends Authenticatable implements MustVerifyEmail use Bannable; use HasApiTokens; use HasCoverPhoto; + + /** @use HasFactory */ use HasFactory; + use HasProfilePhoto; use Notifiable; use Searchable; @@ -44,6 +48,8 @@ class User extends Authenticatable implements MustVerifyEmail /** * The relationship between a user and their mods. + * + * @return BelongsToMany */ public function mods(): BelongsToMany { @@ -97,6 +103,8 @@ class User extends Authenticatable implements MustVerifyEmail /** * The data that is searchable by Scout. + * + * @return array */ public function toSearchableArray(): array { @@ -177,6 +185,8 @@ class User extends Authenticatable implements MustVerifyEmail /** * The relationship between a user and their role. + * + * @return BelongsTo */ public function role(): BelongsTo { @@ -185,6 +195,10 @@ class User extends Authenticatable implements MustVerifyEmail /** * Scope a query by applying QueryFilter filters. + * + * @param Builder $builder + * @param QueryFilter $filters + * @return Builder */ public function scopeFilter(Builder $builder, QueryFilter $filters): Builder { @@ -201,6 +215,8 @@ class User extends Authenticatable implements MustVerifyEmail /** * The attributes that should be cast to native types. + * + * @return array */ protected function casts(): array { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1f3d51..0b307fd 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,8 +11,8 @@ use App\Observers\ModDependencyObserver; use App\Observers\ModObserver; use App\Observers\ModVersionObserver; use App\Observers\SptVersionObserver; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Number; use Illuminate\Support\ServiceProvider; diff --git a/app/Support/Version.php b/app/Support/Version.php new file mode 100644 index 0000000..b96bae3 --- /dev/null +++ b/app/Support/Version.php @@ -0,0 +1,74 @@ +version = $version; + $this->parseVersion(); + } + + /** + * Parse the version string into its components. + * + * @throws InvalidVersionNumberException + */ + protected function parseVersion(): void + { + $matches = []; + + // Regex to match semantic versioning, including pre-release identifiers + if (preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([\w.-]+))?$/', $this->version, $matches)) { + $this->major = (int) $matches[1]; + $this->minor = (int) ($matches[2] ?? 0); + $this->patch = (int) ($matches[3] ?? 0); + $this->preRelease = $matches[4] ?? ''; + } else { + throw new InvalidVersionNumberException('Invalid version number: '.$this->version); + } + } + + public function getMajor(): int + { + return $this->major; + } + + public function getMinor(): int + { + return $this->minor; + } + + public function getPatch(): int + { + return $this->patch; + } + + public function getPreRelease(): string + { + return $this->preRelease; + } + + public function __toString(): string + { + return $this->version; + } +} diff --git a/app/View/Components/HomepageMods.php b/app/View/Components/HomepageMods.php new file mode 100644 index 0000000..74e40ca --- /dev/null +++ b/app/View/Components/HomepageMods.php @@ -0,0 +1,94 @@ + [ + 'title' => __('Featured Mods'), + 'mods' => $this->fetchFeaturedMods(), + 'link' => '/mods?featured=only', + ], + 'latest' => [ + 'title' => __('Newest Mods'), + 'mods' => $this->fetchLatestMods(), + 'link' => '/mods', + ], + 'updated' => [ + 'title' => __('Recently Updated Mods'), + 'mods' => $this->fetchUpdatedMods(), + 'link' => '/mods?order=updated', + ], + ]); + } + + /** + * Fetches the featured mods homepage listing. + * + * @return Collection + */ + private function fetchFeaturedMods(): Collection + { + return Cache::flexible('homepage-featured-mods', [5, 10], function () { + return Mod::whereFeatured(true) + ->with([ + 'latestVersion', + 'latestVersion.latestSptVersion', + 'users:id,name', + 'license:id,name,link', + ]) + ->inRandomOrder() + ->limit(6) + ->get(); + }); + } + + /** + * Fetches the latest mods homepage listing. + * + * @return Collection + */ + private function fetchLatestMods(): Collection + { + return Cache::flexible('homepage-latest-mods', [5, 10], function () { + return Mod::orderByDesc('created_at') + ->with([ + 'latestVersion', + 'latestVersion.latestSptVersion', + 'users:id,name', + 'license:id,name,link', + ]) + ->limit(6) + ->get(); + }); + } + + /** + * Fetches the recently updated mods homepage listing. + * + * @return Collection + */ + private function fetchUpdatedMods(): Collection + { + return Cache::flexible('homepage-updated-mods', [5, 10], function () { + return Mod::orderByDesc('updated_at') + ->with([ + 'latestUpdatedVersion', + 'latestUpdatedVersion.latestSptVersion', + 'users:id,name', + 'license:id,name,link', + ]) + ->limit(6) + ->get(); + }); + } +} diff --git a/app/View/Components/ModList.php b/app/View/Components/ModList.php deleted file mode 100644 index d4a8369..0000000 --- a/app/View/Components/ModList.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ - public Collection $mods; - - public string $versionScope; - - /** - * Create a new component instance. - * - * @param Collection $mods - */ - public function __construct(Collection $mods, string $versionScope) - { - $this->mods = $mods; - $this->versionScope = $versionScope; - } - - public function render(): View - { - return view('components.mod-list', [ - 'mods' => $this->mods, - 'versionScope' => $this->versionScope, - ]); - } -} diff --git a/app/View/Components/ModListSection.php b/app/View/Components/ModListSection.php deleted file mode 100644 index be87d7c..0000000 --- a/app/View/Components/ModListSection.php +++ /dev/null @@ -1,142 +0,0 @@ - - */ - public Collection $modsFeatured; - - /** - * The latest mods listed on the homepage. - * - * @var Collection - */ - public Collection $modsLatest; - - /** - * The last updated mods listed on the homepage. - * - * @var Collection - */ - public Collection $modsUpdated; - - public function __construct() - { - $this->modsFeatured = $this->fetchFeaturedMods(); - $this->modsLatest = $this->fetchLatestMods(); - $this->modsUpdated = $this->fetchUpdatedMods(); - } - - /** - * Fetches the featured mods homepage listing. - * - * @return Collection - */ - private function fetchFeaturedMods(): Collection - { - return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'downloads']) - ->with([ - 'latestVersion', - 'latestVersion.latestSptVersion:id,version,color_class', - 'users:id,name', - 'license:id,name,link', - ]) - ->whereFeatured(true) - ->inRandomOrder() - ->limit(6) - ->get(); - } - - /** - * Fetches the latest mods homepage listing. - * - * @return Collection - */ - private function fetchLatestMods(): Collection - { - return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at', 'downloads']) - ->with([ - 'latestVersion', - 'latestVersion.latestSptVersion:id,version,color_class', - 'users:id,name', - 'license:id,name,link', - ]) - ->latest() - ->limit(6) - ->get(); - } - - /** - * Fetches the recently updated mods homepage listing. - * - * @return Collection - */ - private function fetchUpdatedMods(): Collection - { - return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'downloads']) - ->with([ - 'lastUpdatedVersion', - 'lastUpdatedVersion.latestSptVersion:id,version,color_class', - 'users:id,name', - 'license:id,name,link', - ]) - ->joinSub( - ModVersion::select('mod_id', DB::raw('MAX(updated_at) as latest_updated_at'))->groupBy('mod_id'), - 'latest_versions', - 'mods.id', - '=', - 'latest_versions.mod_id' - ) - ->orderByDesc('latest_versions.latest_updated_at') - ->limit(6) - ->get(); - } - - public function render(): View - { - return view('components.mod-list-section', [ - 'sections' => $this->getSections(), - ]); - } - - /** - * Prepare the sections for the homepage mod lists. - * - * @return array> - */ - public function getSections(): array - { - return [ - [ - 'title' => __('Featured Mods'), - 'mods' => $this->modsFeatured, - 'versionScope' => 'latestVersion', - 'link' => '/mods?featured=only', - ], - [ - 'title' => __('Newest Mods'), - 'mods' => $this->modsLatest, - 'versionScope' => 'latestVersion', - 'link' => '/mods', - ], - [ - 'title' => __('Recently Updated Mods'), - 'mods' => $this->modsUpdated, - 'versionScope' => 'lastUpdatedVersion', - 'link' => '/mods?order=updated', - ], - ]; - } -} diff --git a/app/View/Components/ModListStats.php b/app/View/Components/ModListStats.php deleted file mode 100644 index 3591b3c..0000000 --- a/app/View/Components/ModListStats.php +++ /dev/null @@ -1,21 +0,0 @@ -faker->numerify('#.#.#'); + try { + $version = new Version($versionString); + } catch (\Exception $e) { + $version = new Version('0.0.0'); + } + return [ 'mod_id' => Mod::factory(), - 'version' => fake()->numerify('#.#.#'), + 'version' => $versionString, + 'version_major' => $version->getMajor(), + 'version_minor' => $version->getMinor(), + 'version_patch' => $version->getPatch(), + 'version_pre_release' => $version->getPreRelease(), 'description' => fake()->text(), 'link' => fake()->url(), diff --git a/database/factories/SptVersionFactory.php b/database/factories/SptVersionFactory.php index 707ed46..96acc21 100644 --- a/database/factories/SptVersionFactory.php +++ b/database/factories/SptVersionFactory.php @@ -3,6 +3,7 @@ namespace Database\Factories; use App\Models\SptVersion; +use App\Support\Version; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Carbon; @@ -15,8 +16,19 @@ class SptVersionFactory extends Factory public function definition(): array { + $versionString = $this->faker->numerify('#.#.#'); + try { + $version = new Version($versionString); + } catch (\Exception $e) { + $version = new Version('0.0.0'); + } + return [ - 'version' => $this->faker->numerify('#.#.#'), + 'version' => $versionString, + 'version_major' => $version->getMajor(), + 'version_minor' => $version->getMinor(), + 'version_patch' => $version->getPatch(), + 'version_pre_release' => $version->getPreRelease(), 'color_class' => $this->faker->randomElement(['red', 'green', 'emerald', 'lime', 'yellow', 'grey']), 'link' => $this->faker->url, 'created_at' => Carbon::now(), diff --git a/database/migrations/2024_05_15_023430_create_spt_versions_table.php b/database/migrations/2024_05_15_023430_create_spt_versions_table.php index 1c6a0a0..15c9cf8 100644 --- a/database/migrations/2024_05_15_023430_create_spt_versions_table.php +++ b/database/migrations/2024_05_15_023430_create_spt_versions_table.php @@ -15,18 +15,17 @@ return new class extends Migration ->default(null) ->unique(); $table->string('version'); - $table->unsignedInteger('version_major'); - $table->unsignedInteger('version_minor'); - $table->unsignedInteger('version_patch'); - $table->string('version_pre_release'); + $table->unsignedInteger('version_major')->default(0); + $table->unsignedInteger('version_minor')->default(0); + $table->unsignedInteger('version_patch')->default(0); + $table->string('version_pre_release')->default(''); $table->unsignedInteger('mod_count')->default(0); $table->string('link'); $table->string('color_class'); $table->softDeletes(); $table->timestamps(); - $table->index(['version', 'deleted_at', 'id'], 'spt_versions_filtering_index'); - $table->index(['version_major', 'version_minor', 'version_patch', 'version_pre_release', 'deleted_at'], 'spt_versions_lookup_index'); + $table->index(['version', 'version_major', 'version_minor', 'version_patch', 'version_pre_release'], 'spt_versions_lookup_index'); }); } diff --git a/database/migrations/2024_05_15_023705_create_mod_versions_table.php b/database/migrations/2024_05_15_023705_create_mod_versions_table.php index 0301ead..565716c 100644 --- a/database/migrations/2024_05_15_023705_create_mod_versions_table.php +++ b/database/migrations/2024_05_15_023705_create_mod_versions_table.php @@ -20,6 +20,10 @@ return new class extends Migration ->cascadeOnDelete() ->cascadeOnUpdate(); $table->string('version'); + $table->unsignedInteger('version_major')->default(0); + $table->unsignedInteger('version_minor')->default(0); + $table->unsignedInteger('version_patch')->default(0); + $table->string('version_pre_release')->default(''); $table->longText('description'); $table->string('link'); $table->string('spt_version_constraint'); @@ -32,6 +36,7 @@ return new class extends Migration $table->index(['version']); $table->index(['mod_id', 'deleted_at', 'disabled', 'published_at'], 'mod_versions_filtering_index'); + $table->index(['version_major', 'version_minor', 'version_patch', 'version_pre_release'], 'mod_versions_version_components_index'); $table->index(['id', 'deleted_at'], 'mod_versions_id_deleted_at_index'); }); } diff --git a/database/migrations/2024_05_15_023710_create_mod_version_spt_version_table.php b/database/migrations/2024_05_15_023710_create_mod_version_spt_version_table.php index 2848d91..1084253 100644 --- a/database/migrations/2024_05_15_023710_create_mod_version_spt_version_table.php +++ b/database/migrations/2024_05_15_023710_create_mod_version_spt_version_table.php @@ -12,7 +12,6 @@ return new class extends Migration $table->id(); $table->foreignId('mod_version_id')->constrained('mod_versions')->cascadeOnDelete()->cascadeOnUpdate(); $table->foreignId('spt_version_id')->constrained('spt_versions')->cascadeOnDelete()->cascadeOnUpdate(); - $table->timestamps(); $table->index(['mod_version_id', 'spt_version_id'], 'mod_version_spt_version_index'); $table->index(['spt_version_id', 'mod_version_id'], 'spt_version_mod_version_index'); diff --git a/package-lock.json b/package-lock.json index e7fbced..485249e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -556,9 +556,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.2.tgz", - "integrity": "sha512-fSuPrt0ZO8uXeS+xP3b+yYTCBUd05MoSp2N/MFOgjhhUhMmchXlpTQrTpI8T+YAwAQuK7MafsCOxW7VrPMrJcg==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", + "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", "cpu": [ "arm" ], @@ -570,9 +570,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.2.tgz", - "integrity": "sha512-xGU5ZQmPlsjQS6tzTTGwMsnKUtu0WVbl0hYpTPauvbRAnmIvpInhJtgjj3mcuJpEiuUw4v1s4BimkdfDWlh7gA==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", + "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", "cpu": [ "arm64" ], @@ -584,9 +584,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.2.tgz", - "integrity": "sha512-99AhQ3/ZMxU7jw34Sq8brzXqWH/bMnf7ZVhvLk9QU2cOepbQSVTns6qoErJmSiAvU3InRqC2RRZ5ovh1KN0d0Q==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", + "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", "cpu": [ "arm64" ], @@ -598,9 +598,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.2.tgz", - "integrity": "sha512-ZbRaUvw2iN/y37x6dY50D8m2BnDbBjlnMPotDi/qITMJ4sIxNY33HArjikDyakhSv0+ybdUxhWxE6kTI4oX26w==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", + "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", "cpu": [ "x64" ], @@ -612,9 +612,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.2.tgz", - "integrity": "sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", + "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", "cpu": [ "arm" ], @@ -626,9 +626,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.2.tgz", - "integrity": "sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", + "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", "cpu": [ "arm" ], @@ -640,9 +640,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.2.tgz", - "integrity": "sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", + "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", "cpu": [ "arm64" ], @@ -654,9 +654,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.2.tgz", - "integrity": "sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", + "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", "cpu": [ "arm64" ], @@ -668,9 +668,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.2.tgz", - "integrity": "sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", + "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", "cpu": [ "ppc64" ], @@ -682,9 +682,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.2.tgz", - "integrity": "sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", + "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", "cpu": [ "riscv64" ], @@ -696,9 +696,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.2.tgz", - "integrity": "sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", + "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", "cpu": [ "s390x" ], @@ -710,9 +710,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.2.tgz", - "integrity": "sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", + "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", "cpu": [ "x64" ], @@ -724,9 +724,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.2.tgz", - "integrity": "sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", + "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", "cpu": [ "x64" ], @@ -738,9 +738,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.2.tgz", - "integrity": "sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", + "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", "cpu": [ "arm64" ], @@ -752,9 +752,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.2.tgz", - "integrity": "sha512-5rA4vjlqgrpbFVVHX3qkrCo/fZTj1q0Xxpg+Z7yIo3J2AilW7t2+n6Q8Jrx+4MrYpAnjttTYF8rr7bP46BPzRw==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", + "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", "cpu": [ "ia32" ], @@ -766,9 +766,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.2.tgz", - "integrity": "sha512-6UUxd0+SKomjdzuAcp+HAmxw1FlGBnl1v2yEPSabtx4lBfdXHDVsW7+lQkgz9cNFJGY3AWR7+V8P5BqkD9L9nA==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", + "integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", "cpu": [ "x64" ], @@ -816,9 +816,9 @@ "license": "MIT" }, "node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, "license": "MIT", "engines": { @@ -1013,9 +1013,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001658", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001658.tgz", - "integrity": "sha512-N2YVqWbJELVdrnsW5p+apoQyYt51aBMSsBZki1XZEfeBCexcM/sf4xiAHcXQBkuOwJBXtWF7aW1sYX6tKebPHw==", + "version": "1.0.30001660", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001660.tgz", + "integrity": "sha512-GacvNTTuATm26qC74pt+ad1fW15mlQ/zuTzzY1ZoIzECTP8HURDfF43kNxPgf7H1jmelCBQTTbBNxdSXOA7Bqg==", "dev": true, "funding": [ { @@ -1161,9 +1161,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.18", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.18.tgz", - "integrity": "sha512-1OfuVACu+zKlmjsNdcJuVQuVE61sZOLbNM4JAQ1Rvh6EOj0/EUKhMJjRH73InPlXSh8HIJk1cVZ8pyOV/FMdUQ==", + "version": "1.5.23", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.23.tgz", + "integrity": "sha512-mBhODedOXg4v5QWwl21DjM5amzjmI1zw9EPrPK/5Wx7C8jt33bpZNrC7OhHUG3pxRtbLpr3W2dXT+Ph1SsfRZA==", "dev": true, "license": "ISC" }, @@ -1824,9 +1824,9 @@ } }, "node_modules/postcss": { - "version": "8.4.45", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz", - "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -1845,8 +1845,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -2176,9 +2176,9 @@ } }, "node_modules/rollup": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.2.tgz", - "integrity": "sha512-e3TapAgYf9xjdLvKQCkQTnbTKd4a6jwlpQSJJFokHGaX2IVjoEqkIIhiQfqsi0cdwlOD+tQGuOd5AJkc5RngBw==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", + "integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", "dev": true, "license": "MIT", "dependencies": { @@ -2192,22 +2192,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.21.2", - "@rollup/rollup-android-arm64": "4.21.2", - "@rollup/rollup-darwin-arm64": "4.21.2", - "@rollup/rollup-darwin-x64": "4.21.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.21.2", - "@rollup/rollup-linux-arm-musleabihf": "4.21.2", - "@rollup/rollup-linux-arm64-gnu": "4.21.2", - "@rollup/rollup-linux-arm64-musl": "4.21.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.21.2", - "@rollup/rollup-linux-riscv64-gnu": "4.21.2", - "@rollup/rollup-linux-s390x-gnu": "4.21.2", - "@rollup/rollup-linux-x64-gnu": "4.21.2", - "@rollup/rollup-linux-x64-musl": "4.21.2", - "@rollup/rollup-win32-arm64-msvc": "4.21.2", - "@rollup/rollup-win32-ia32-msvc": "4.21.2", - "@rollup/rollup-win32-x64-msvc": "4.21.2", + "@rollup/rollup-android-arm-eabi": "4.21.3", + "@rollup/rollup-android-arm64": "4.21.3", + "@rollup/rollup-darwin-arm64": "4.21.3", + "@rollup/rollup-darwin-x64": "4.21.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.3", + "@rollup/rollup-linux-arm-musleabihf": "4.21.3", + "@rollup/rollup-linux-arm64-gnu": "4.21.3", + "@rollup/rollup-linux-arm64-musl": "4.21.3", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", + "@rollup/rollup-linux-riscv64-gnu": "4.21.3", + "@rollup/rollup-linux-s390x-gnu": "4.21.3", + "@rollup/rollup-linux-x64-gnu": "4.21.3", + "@rollup/rollup-linux-x64-musl": "4.21.3", + "@rollup/rollup-win32-arm64-msvc": "4.21.3", + "@rollup/rollup-win32-ia32-msvc": "4.21.3", + "@rollup/rollup-win32-x64-msvc": "4.21.3", "fsevents": "~2.3.2" } }, @@ -2428,9 +2428,9 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", - "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.11.tgz", + "integrity": "sha512-qhEuBcLemjSJk5ajccN9xJFtM/h0AVCPaA6C92jNP+M2J8kX+eMJHI7R2HFKUvvAsMpcfLILMCFYSeDwpMmlUg==", "dev": true, "license": "MIT", "dependencies": { @@ -2574,9 +2574,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.3.tgz", - "integrity": "sha512-IH+nl64eq9lJjFqU+/yrRnrHPVTlgy42/+IzbOdaFDVlyLgI/wDlf+FCobXLX1cT0X5+7LMyH1mIy2xJdLfo8Q==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.5.tgz", + "integrity": "sha512-pXqR0qtb2bTwLkev4SE3r4abCNioP3GkjvIDLlzziPpXtHgiJIjuKl+1GN6ESOT3wMjG3JTeARopj2SwYaHTOA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/resources/views/components/homepage-mods.blade.php b/resources/views/components/homepage-mods.blade.php new file mode 100644 index 0000000..eade9a1 --- /dev/null +++ b/resources/views/components/homepage-mods.blade.php @@ -0,0 +1,22 @@ +
+ +
+ @foreach ($featured['mods'] as $mod) + + @endforeach +
+ + +
+ @foreach ($latest['mods'] as $mod) + + @endforeach +
+ + +
+ @foreach ($updated['mods'] as $mod) + + @endforeach +
+
diff --git a/resources/views/components/mod-card.blade.php b/resources/views/components/mod-card.blade.php index 361be56..3aff1ef 100644 --- a/resources/views/components/mod-card.blade.php +++ b/resources/views/components/mod-card.blade.php @@ -1,37 +1,56 @@ -@props(['mod', 'versionScope' => 'none']) +@props(['mod', 'version']) - + @if ($mod->featured && !request()->routeIs('home'))
{{ __('Featured!') }}
@endif
- @if (empty($mod->thumbnail)) + @empty($mod->thumbnail) {{ $mod->name }} @else {{ $mod->name }} - @endif + @endempty

{{ $mod->name }}

- @if($versionScope !== 'none' && $mod->{$versionScope} !== null && $mod->{$versionScope}->latestSptVersion !== null) - - {{ $mod->{$versionScope}->latestSptVersion->first()->version_formatted }} + + {{ $version->latestSptVersion->version_formatted }} - @endif

- By {{ $mod->users->pluck('name')->implode(', ') }} + {{ __('By :authors', ['authors' => $mod->users->pluck('name')->implode(', ')]) }} +

+

+ {{ Str::limit($mod->teaser, 100) }}

-

{{ Str::limit($mod->teaser, 100) }}

- @if($versionScope !== 'none' && $mod->{$versionScope} !== null) - - @endif +
+
+ @if ($mod->updated_at || $mod->created_at) +
+
+ + + + +
+
+ @endif +
+ + {{ Number::downloads($mod->downloads) }} + + + + +
+
+
diff --git a/resources/views/components/mod-list-section-partial.blade.php b/resources/views/components/mod-list-section-partial.blade.php deleted file mode 100644 index 21dc3f9..0000000 --- a/resources/views/components/mod-list-section-partial.blade.php +++ /dev/null @@ -1,6 +0,0 @@ -@props(['mods', 'versionScope', 'title', 'link']) - -
- - -
diff --git a/resources/views/components/mod-list-section.blade.php b/resources/views/components/mod-list-section.blade.php deleted file mode 100644 index 15a0daa..0000000 --- a/resources/views/components/mod-list-section.blade.php +++ /dev/null @@ -1,8 +0,0 @@ -@foreach ($sections as $section) - @include('components.mod-list-section-partial', [ - 'title' => $section['title'], - 'mods' => $section['mods'], - 'versionScope' => $section['versionScope'], - 'link' => $section['link'] - ]) -@endforeach diff --git a/resources/views/components/mod-list-stats.blade.php b/resources/views/components/mod-list-stats.blade.php deleted file mode 100644 index ab2cdd2..0000000 --- a/resources/views/components/mod-list-stats.blade.php +++ /dev/null @@ -1,32 +0,0 @@ -

class(['text-slate-700 dark:text-gray-300 text-sm']) }}> -

-
-
- - - - - @if(!is_null($mod->updated_at)) - - @elseif(!is_null($mod->created_at)) - - @else - N/A - @endif - -
-
-
- - {{ Number::downloads($mod->downloads) }} - - - - -
-
-

diff --git a/resources/views/components/mod-list.blade.php b/resources/views/components/mod-list.blade.php deleted file mode 100644 index 956f6c2..0000000 --- a/resources/views/components/mod-list.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@props(['mods', 'versionScope']) - -
- @foreach ($mods as $mod) - @if ($mod->{$versionScope}) - - @endif - @endforeach -
diff --git a/resources/views/components/page-content-title.blade.php b/resources/views/components/page-content-title.blade.php index 44816ba..ae0034d 100644 --- a/resources/views/components/page-content-title.blade.php +++ b/resources/views/components/page-content-title.blade.php @@ -2,7 +2,7 @@

{{ __($title) }}

- @if (isset($buttonText) && isset($buttonLink)) + @if ($buttonText && $buttonLink)
- +
diff --git a/resources/views/livewire/mod/index.blade.php b/resources/views/livewire/mod/listing.blade.php similarity index 99% rename from resources/views/livewire/mod/index.blade.php rename to resources/views/livewire/mod/listing.blade.php index b8167e4..94a6318 100644 --- a/resources/views/livewire/mod/index.blade.php +++ b/resources/views/livewire/mod/listing.blade.php @@ -127,7 +127,7 @@ @if ($mods->isNotEmpty())
@foreach ($mods as $mod) - + @endforeach
@else diff --git a/resources/views/mod/index.blade.php b/resources/views/mod/index.blade.php index e2030b6..c611868 100644 --- a/resources/views/mod/index.blade.php +++ b/resources/views/mod/index.blade.php @@ -1,3 +1,3 @@ - @livewire('mod.index') + @livewire('mod.listing') diff --git a/resources/views/mod/show.blade.php b/resources/views/mod/show.blade.php index 42833be..e6350d5 100644 --- a/resources/views/mod/show.blade.php +++ b/resources/views/mod/show.blade.php @@ -40,8 +40,8 @@

{{ Number::downloads($mod->downloads) }} {{ __('Downloads') }}

- - {{ $mod->latestVersion->latestSptVersion->first()->version_formatted }} {{ __('Compatible') }} + + {{ $mod->latestVersion->latestSptVersion->version_formatted }} {{ __('Compatible') }}

@@ -108,8 +108,8 @@

{{ Number::downloads($version->downloads) }} {{ __('Downloads') }}

diff --git a/tests/Feature/Mod/ModFilterTest.php b/tests/Feature/Mod/ModFilterTest.php index 6e4d998..a2b5b26 100644 --- a/tests/Feature/Mod/ModFilterTest.php +++ b/tests/Feature/Mod/ModFilterTest.php @@ -99,6 +99,36 @@ it('returns no mods when no SPT versions match', function () { expect($filteredMods)->toBeEmpty(); }); +it('filters mods based on a exact search term', function () { + SptVersion::factory()->create(['version' => '1.0.0']); + + $mod = Mod::factory()->create(['name' => 'BigBrain']); + ModVersion::factory()->recycle($mod)->create(['spt_version_constraint' => '^1.0.0']); + + Mod::factory()->create(['name' => 'SmallFeet']); + ModVersion::factory()->recycle($mod)->create(['spt_version_constraint' => '^1.0.0']); + + $filters = ['query' => 'BigBrain']; + $filteredMods = (new ModFilter($filters))->apply()->get(); + + expect($filteredMods)->toHaveCount(1)->and($filteredMods->first()->id)->toBe($mod->id); +}); + +it('filters mods based featured status', function () { + SptVersion::factory()->create(['version' => '1.0.0']); + + $mod = Mod::factory()->create(['name' => 'BigBrain', 'featured' => true]); + ModVersion::factory()->recycle($mod)->create(['spt_version_constraint' => '^1.0.0']); + + Mod::factory()->create(['name' => 'SmallFeet']); + ModVersion::factory()->recycle($mod)->create(['spt_version_constraint' => '^1.0.0']); + + $filters = ['featured' => true]; + $filteredMods = (new ModFilter($filters))->apply()->get(); + + expect($filteredMods)->toHaveCount(1)->and($filteredMods->first()->id)->toBe($mod->id); +}); + it('filters mods correctly with combined filters', function () { // Create the SPT versions $sptVersion1 = SptVersion::factory()->create(['version' => '1.0.0']); @@ -128,8 +158,7 @@ it('filters mods correctly with combined filters', function () { $filteredMods = (new ModFilter($filters))->apply()->get(); // Assert that only the correct mod is returned - expect($filteredMods)->toHaveCount(1) - ->and($filteredMods->first()->id)->toBe($mod1->id); + expect($filteredMods)->toHaveCount(1)->and($filteredMods->first()->id)->toBe($mod1->id); }); it('handles an empty SPT versions array correctly', function () {