Commit workspace changes

This commit is contained in:
2026-04-05 19:42:33 +02:00
parent 148a3bbe43
commit 08ad757bcb
312 changed files with 35149 additions and 399 deletions

View File

@@ -1,6 +1,8 @@
<?php
namespace App\Models;
use App\Models\ArtworkContributor;
use App\Models\Group;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -26,6 +28,9 @@ class Artwork extends Model
{
use HasFactory, SoftDeletes, Searchable;
public const PUBLISHED_AS_USER = 'user';
public const PUBLISHED_AS_GROUP = 'group';
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_UNLISTED = 'unlisted';
public const VISIBILITY_PRIVATE = 'private';
@@ -34,6 +39,11 @@ class Artwork extends Model
protected $fillable = [
'user_id',
'group_id',
'uploaded_by_user_id',
'primary_author_user_id',
'published_as_type',
'published_as_id',
'title',
'slug',
'description',
@@ -81,6 +91,8 @@ class Artwork extends Model
'is_approved' => 'boolean',
'is_mature' => 'boolean',
'published_at' => 'datetime',
'published_as_type' => 'string',
'published_as_id' => 'integer',
'publish_at' => 'datetime',
'clip_tags_json' => 'array',
'yolo_objects_json' => 'array',
@@ -167,6 +179,51 @@ class Artwork extends Model
return $this->belongsTo(User::class);
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function uploadedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by_user_id');
}
public function primaryAuthor(): BelongsTo
{
return $this->belongsTo(User::class, 'primary_author_user_id');
}
public function contributors(): HasMany
{
return $this->hasMany(ArtworkContributor::class)->orderBy('sort_order');
}
public function isPublishedByGroup(): bool
{
return $this->publishedAsType() === self::PUBLISHED_AS_GROUP;
}
public function publishedAsType(): string
{
if (in_array($this->published_as_type, [self::PUBLISHED_AS_USER, self::PUBLISHED_AS_GROUP], true)) {
return (string) $this->published_as_type;
}
return (int) ($this->group_id ?? 0) > 0 ? self::PUBLISHED_AS_GROUP : self::PUBLISHED_AS_USER;
}
public function publishedAsId(): int
{
if ((int) ($this->published_as_id ?? 0) > 0) {
return (int) $this->published_as_id;
}
return $this->publishedAsType() === self::PUBLISHED_AS_GROUP
? (int) ($this->group_id ?? 0)
: (int) $this->user_id;
}
public function translations(): HasMany
{
return $this->hasMany(ArtworkTranslation::class);
@@ -268,7 +325,7 @@ class Artwork extends Model
*/
public function toSearchableArray(): array
{
$this->loadMissing(['user', 'tags', 'categories.contentType', 'stats', 'awardStat']);
$this->loadMissing(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat']);
$stat = $this->stats;
$awardStat = $this->awardStat;
@@ -301,8 +358,9 @@ class Artwork extends Model
'slug' => $this->slug,
'title' => $this->title,
'description' => (string) ($this->description ?? ''),
'author_id' => $this->user_id,
'author_name' => $this->user?->name ?? 'Skinbase',
'author_id' => $this->publishedAsId(),
'author_name' => $this->group?->name ?? $this->user?->name ?? 'Skinbase',
'published_as_type' => $this->publishedAsType(),
'category' => $category,
'content_type' => $content_type,
'tags' => $tags,

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArtworkContributor extends Model
{
use HasFactory;
protected $fillable = [
'artwork_id',
'user_id',
'credit_role',
'is_primary',
'sort_order',
];
protected $casts = [
'is_primary' => 'boolean',
'sort_order' => 'integer',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -11,6 +11,7 @@ use App\Models\CollectionMember;
use App\Models\CollectionSave;
use App\Models\CollectionSurfacePlacement;
use App\Models\CollectionSubmission;
use App\Models\Group;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -120,6 +121,7 @@ class Collection extends Model
protected $fillable = [
'user_id',
'group_id',
'managed_by_user_id',
'title',
'slug',
@@ -263,6 +265,11 @@ class Collection extends Model
return $this->belongsTo(User::class);
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function managedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'managed_by_user_id');
@@ -448,7 +455,17 @@ class Collection extends Model
{
$userId = $user instanceof User ? $user->id : $user;
return $userId !== null && (int) $userId === (int) $this->user_id;
if ($userId === null) {
return false;
}
if ((int) ($this->group_id ?? 0) > 0) {
$group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first();
return $group?->hasActiveMember((int) $userId) ?? false;
}
return (int) $userId === (int) $this->user_id;
}
public function isPubliclyAccessible(): bool
@@ -488,6 +505,12 @@ class Collection extends Model
public function displayOwnerName(): string
{
if ((int) ($this->group_id ?? 0) > 0) {
$group = $this->relationLoaded('group') ? $this->group : $this->group()->first();
return (string) ($group?->name ?: 'Skinbase Group');
}
if ($this->type === self::TYPE_EDITORIAL && $this->editorial_owner_mode === self::EDITORIAL_OWNER_SYSTEM) {
return (string) ($this->editorial_owner_label ?: config('collections.editorial.system_owner_label', 'Skinbase Editorial'));
}
@@ -499,6 +522,10 @@ class Collection extends Model
public function displayOwnerUsername(): ?string
{
if ((int) ($this->group_id ?? 0) > 0) {
return null;
}
if ($this->type === self::TYPE_EDITORIAL && $this->editorial_owner_mode === self::EDITORIAL_OWNER_SYSTEM) {
return null;
}
@@ -526,6 +553,12 @@ class Collection extends Model
return null;
}
if ((int) ($this->group_id ?? 0) > 0) {
$group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first();
return $group?->activeRoleFor((int) $userId);
}
if ($this->isOwnedBy($userId)) {
return self::MEMBER_ROLE_OWNER;
}
@@ -546,10 +579,13 @@ class Collection extends Model
public function canBeManagedBy(User $user): bool
{
return in_array($this->activeMemberRoleFor($user), [
self::MEMBER_ROLE_OWNER,
self::MEMBER_ROLE_EDITOR,
], true);
if ((int) ($this->group_id ?? 0) > 0) {
$group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first();
return $group?->canManageCollections($user) ?? false;
}
return in_array($this->activeMemberRoleFor($user), [self::MEMBER_ROLE_OWNER, self::MEMBER_ROLE_EDITOR], true);
}
public function canManageArtworks(User $user): bool
@@ -559,6 +595,12 @@ class Collection extends Model
public function canManageMembers(User $user): bool
{
if ((int) ($this->group_id ?? 0) > 0) {
$group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first();
return $group?->canManageMembers($user) ?? false;
}
return $this->isCollaborative() && $this->canBeManagedBy($user);
}

868
app/Models/Group.php Normal file
View File

@@ -0,0 +1,868 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable;
class Group extends Model
{
use HasFactory;
use SoftDeletes;
use Searchable;
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_PRIVATE = 'private';
public const VISIBILITY_UNLISTED = 'unlisted';
public const LIFECYCLE_ACTIVE = 'active';
public const LIFECYCLE_ARCHIVED = 'archived';
public const LIFECYCLE_SUSPENDED = 'suspended';
public const MEMBERSHIP_INVITE_ONLY = 'invite_only';
public const MEMBERSHIP_REQUEST_TO_JOIN = 'request_to_join';
public const MEMBERSHIP_OPEN = 'open';
public const ROLE_OWNER = 'owner';
public const ROLE_ADMIN = 'admin';
public const ROLE_EDITOR = 'editor';
public const ROLE_MEMBER = 'member';
public const ROLE_CONTRIBUTOR = 'contributor';
public const PERMISSION_REVIEW_JOIN_REQUESTS = 'review_join_requests';
public const PERMISSION_REVIEW_SUBMISSIONS = 'review_submissions';
public const PERMISSION_MANAGE_RECRUITMENT = 'manage_recruitment';
public const PERMISSION_MANAGE_POSTS = 'manage_posts';
public const PERMISSION_PUBLISH_POSTS = 'publish_posts';
public const PERMISSION_PIN_POSTS = 'pin_posts';
public const PERMISSION_MANAGE_MEMBER_PERMISSIONS = 'manage_member_permissions';
public const PERMISSION_MANAGE_EVENTS = 'manage_events';
public const PERMISSION_MANAGE_CHALLENGES = 'manage_challenges';
public const PERMISSION_MANAGE_PROJECTS = 'manage_projects';
public const PERMISSION_MANAGE_RELEASES = 'manage_releases';
public const PERMISSION_PUBLISH_RELEASES = 'publish_releases';
public const PERMISSION_MANAGE_MILESTONES = 'manage_milestones';
public const PERMISSION_VIEW_REPUTATION_DASHBOARD = 'view_reputation_dashboard';
public const PERMISSION_MANAGE_BADGES = 'manage_badges';
public const PERMISSION_VIEW_INTERNAL_TRUST_METRICS = 'view_internal_trust_metrics';
public const PERMISSION_FEATURE_RELEASES = 'feature_releases';
public const PERMISSION_ASSIGN_RELEASE_LEAD = 'assign_release_lead';
public const PERMISSION_MANAGE_ASSETS = 'manage_assets';
public const PERMISSION_FEATURE_CHALLENGE_ENTRIES = 'feature_challenge_entries';
public const PERMISSION_PUBLISH_EVENT_UPDATES = 'publish_event_updates';
public const PERMISSION_ATTACH_ASSETS_TO_PROJECTS = 'attach_assets_to_projects';
public const PERMISSION_VIEW_INTERNAL_ASSETS = 'view_internal_assets';
public const PERMISSION_MANAGE_ACTIVITY_PINS = 'manage_activity_pins';
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_REVOKED = 'revoked';
protected $fillable = [
'owner_user_id',
'featured_artwork_id',
'is_verified',
'founded_at',
'name',
'slug',
'headline',
'bio',
'type',
'visibility',
'status',
'membership_policy',
'website_url',
'links_json',
'avatar_path',
'banner_path',
'artworks_count',
'collections_count',
'followers_count',
'last_activity_at',
];
protected $casts = [
'links_json' => 'array',
'is_verified' => 'boolean',
'artworks_count' => 'integer',
'collections_count' => 'integer',
'followers_count' => 'integer',
'founded_at' => 'datetime',
'last_activity_at' => 'datetime',
];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
public function getRouteKeyName(): string
{
return 'slug';
}
public function members(): HasMany
{
return $this->hasMany(GroupMember::class);
}
public function invitations(): HasMany
{
return $this->hasMany(GroupInvitation::class);
}
public function follows(): HasMany
{
return $this->hasMany(GroupFollow::class);
}
public function artworks(): HasMany
{
return $this->hasMany(Artwork::class);
}
public function collections(): HasMany
{
return $this->hasMany(Collection::class);
}
public function joinRequests(): HasMany
{
return $this->hasMany(GroupJoinRequest::class);
}
public function posts(): HasMany
{
return $this->hasMany(GroupPost::class);
}
public function recruitmentProfile(): HasOne
{
return $this->hasOne(GroupRecruitmentProfile::class);
}
public function projects(): HasMany
{
return $this->hasMany(GroupProject::class);
}
public function releases(): HasMany
{
return $this->hasMany(GroupRelease::class);
}
public function challenges(): HasMany
{
return $this->hasMany(GroupChallenge::class);
}
public function events(): HasMany
{
return $this->hasMany(GroupEvent::class);
}
public function assets(): HasMany
{
return $this->hasMany(GroupAsset::class);
}
public function activityItems(): HasMany
{
return $this->hasMany(GroupActivityItem::class);
}
public function contributorStats(): HasMany
{
return $this->hasMany(GroupContributorStat::class);
}
public function badges(): HasMany
{
return $this->hasMany(GroupBadge::class);
}
public function memberBadges(): HasMany
{
return $this->hasMany(GroupMemberBadge::class);
}
public function discoveryMetric(): HasOne
{
return $this->hasOne(GroupDiscoveryMetric::class);
}
public function historyEntries(): HasMany
{
return $this->hasMany(GroupHistory::class);
}
public function scopePublic(Builder $query): Builder
{
return $query
->where('visibility', self::VISIBILITY_PUBLIC)
->where('status', self::LIFECYCLE_ACTIVE);
}
public static function acceptedVisibilityValues(): array
{
return [self::VISIBILITY_PUBLIC, self::VISIBILITY_PRIVATE, self::VISIBILITY_UNLISTED];
}
public static function acceptedMembershipPolicies(): array
{
return [self::MEMBERSHIP_INVITE_ONLY, self::MEMBERSHIP_REQUEST_TO_JOIN, self::MEMBERSHIP_OPEN];
}
public static function normalizeMemberRole(string $role): string
{
$normalized = strtolower(trim($role));
return $normalized === self::ROLE_CONTRIBUTOR ? self::ROLE_MEMBER : $normalized;
}
public static function displayRole(?string $role): ?string
{
if ($role === null) {
return null;
}
return self::normalizeMemberRole($role) === self::ROLE_MEMBER ? self::ROLE_CONTRIBUTOR : self::normalizeMemberRole($role);
}
public function isOwnedBy(User|int|null $user): bool
{
$userId = $user instanceof User ? $user->id : $user;
return $userId !== null && (int) $userId === (int) $this->owner_user_id;
}
public function isPubliclyVisible(): bool
{
return in_array($this->visibility, [self::VISIBILITY_PUBLIC, self::VISIBILITY_UNLISTED], true)
&& $this->status !== self::LIFECYCLE_SUSPENDED;
}
public function isOperational(): bool
{
return $this->status === self::LIFECYCLE_ACTIVE;
}
public function canArchive(User $user): bool
{
return $this->isOwnedBy($user);
}
public function canViewStudio(User $user): bool
{
if ($this->status === self::LIFECYCLE_SUSPENDED) {
return false;
}
return $this->hasActiveMember($user);
}
public function activeRoleFor(User|int|null $user): ?string
{
$userId = $user instanceof User ? $user->id : $user;
if ($userId === null) {
return null;
}
if ($this->isOwnedBy($userId)) {
return self::ROLE_OWNER;
}
$members = $this->relationLoaded('members')
? $this->members
: $this->members()->where('status', self::STATUS_ACTIVE)->get();
return $members
->first(fn (GroupMember $member): bool => (int) $member->user_id === (int) $userId && $member->status === self::STATUS_ACTIVE)
?->role;
}
public function hasActiveMember(User|int|null $user): bool
{
return $this->activeRoleFor($user) !== null;
}
public function activeMembershipFor(User|int|null $user): ?GroupMember
{
$userId = $user instanceof User ? $user->id : $user;
if ($userId === null || $this->isOwnedBy($userId)) {
return null;
}
$members = $this->relationLoaded('members')
? $this->members
: $this->members()->where('status', self::STATUS_ACTIVE)->get();
return $members->first(
fn (GroupMember $member): bool => (int) $member->user_id === (int) $userId && $member->status === self::STATUS_ACTIVE
);
}
public function permissionOverridesFor(User|int|null $user): array
{
if ($this->isOwnedBy($user)) {
return collect(self::allowedPermissionOverrides())
->mapWithKeys(fn (string $permission): array => [$permission => true])
->all();
}
$member = $this->activeMembershipFor($user);
if (! $member) {
return [];
}
return collect($member->permission_overrides_json ?? [])
->mapWithKeys(function ($override): array {
if (is_array($override)) {
$key = trim((string) ($override['key'] ?? ''));
if ($key === '' || ! in_array($key, self::allowedPermissionOverrides(), true)) {
return [];
}
return [$key => (bool) ($override['is_allowed'] ?? false)];
}
$key = trim((string) $override);
if ($key === '' || ! in_array($key, self::allowedPermissionOverrides(), true)) {
return [];
}
return [$key => true];
})
->all();
}
public function hasPermission(User|int|null $user, string $permission): bool
{
return $this->permissionOverridesFor($user)[$permission] ?? false;
}
public function hasDeniedPermission(User|int|null $user, string $permission): bool
{
$overrides = $this->permissionOverridesFor($user);
return array_key_exists($permission, $overrides) && $overrides[$permission] === false;
}
public static function permissionKeys(): array
{
return self::allowedPermissionOverrides();
}
public function canBeViewedBy(?User $user): bool
{
if ($this->isPubliclyVisible()) {
return true;
}
return $user !== null && $this->hasActiveMember($user);
}
public function canManage(User $user): bool
{
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true);
}
public function canManageMembers(User $user): bool
{
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true);
}
public function canPublishArtworks(User $user): bool
{
return $this->isOperational()
&& in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true);
}
public function canCreateArtworkDrafts(User $user): bool
{
return $this->isOperational() && $this->hasActiveMember($user);
}
public function canSubmitArtworkForReview(User $user): bool
{
return $this->isOperational() && $this->hasActiveMember($user);
}
public function canReviewSubmissions(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_REVIEW_SUBMISSIONS)) {
return false;
}
$role = $this->activeRoleFor($user);
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_REVIEW_SUBMISSIONS);
}
public function canRequestJoin(?User $user): bool
{
if (! $this->isOperational() || $user === null || $this->hasActiveMember($user)) {
return false;
}
return in_array($this->membership_policy, [self::MEMBERSHIP_REQUEST_TO_JOIN, self::MEMBERSHIP_OPEN], true);
}
public function canReviewJoinRequests(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_REVIEW_JOIN_REQUESTS)) {
return false;
}
$role = $this->activeRoleFor($user);
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_REVIEW_JOIN_REQUESTS);
}
public function canManageRecruitment(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_RECRUITMENT)) {
return false;
}
$role = $this->activeRoleFor($user);
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_RECRUITMENT);
}
public function canManagePosts(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_POSTS)) {
return false;
}
$role = $this->activeRoleFor($user);
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_POSTS);
}
public function canPublishPosts(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_PUBLISH_POSTS)) {
return false;
}
$role = $this->activeRoleFor($user);
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_PUBLISH_POSTS)
|| $this->canManagePosts($user);
}
public function canPinPosts(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_PIN_POSTS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_PIN_POSTS);
}
public function canManageMemberPermissions(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_MEMBER_PERMISSIONS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_MEMBER_PERMISSIONS);
}
public function canManageEvents(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_EVENTS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_EVENTS);
}
public function canManageChallenges(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_CHALLENGES)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_CHALLENGES);
}
public function canManageProjects(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_PROJECTS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_PROJECTS);
}
public function canManageReleases(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_RELEASES)) {
return false;
}
return $this->canManageProjects($user)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_RELEASES);
}
public function canPublishReleases(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_PUBLISH_RELEASES)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_PUBLISH_RELEASES)
|| $this->canManageReleases($user);
}
public function canManageMilestones(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_MILESTONES)) {
return false;
}
return $this->canManageProjects($user)
|| $this->canManageReleases($user)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_MILESTONES);
}
public function canViewReputationDashboard(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_VIEW_REPUTATION_DASHBOARD)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_VIEW_REPUTATION_DASHBOARD);
}
public function canManageBadges(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_BADGES)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_BADGES);
}
public function canViewInternalTrustMetrics(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_VIEW_INTERNAL_TRUST_METRICS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_VIEW_INTERNAL_TRUST_METRICS);
}
public function canFeatureReleases(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_FEATURE_RELEASES)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_FEATURE_RELEASES);
}
public function canAssignReleaseLead(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_ASSIGN_RELEASE_LEAD)) {
return false;
}
return $this->canManageReleases($user)
|| $this->hasPermission($user, self::PERMISSION_ASSIGN_RELEASE_LEAD);
}
public function canManageAssets(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_ASSETS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_ASSETS);
}
public function canFeatureChallengeEntries(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_FEATURE_CHALLENGE_ENTRIES)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_FEATURE_CHALLENGE_ENTRIES);
}
public function canPublishEventUpdates(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_PUBLISH_EVENT_UPDATES)) {
return false;
}
return $this->canManageEvents($user)
|| $this->hasPermission($user, self::PERMISSION_PUBLISH_EVENT_UPDATES);
}
public function canAttachAssetsToProjects(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_ATTACH_ASSETS_TO_PROJECTS)) {
return false;
}
return $this->canManageProjects($user)
|| $this->hasPermission($user, self::PERMISSION_ATTACH_ASSETS_TO_PROJECTS);
}
public function canViewInternalAssets(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_VIEW_INTERNAL_ASSETS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_VIEW_INTERNAL_ASSETS);
}
public function canPinActivity(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_ACTIVITY_PINS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_ACTIVITY_PINS);
}
public static function allowedPermissionOverrides(): array
{
return [
self::PERMISSION_REVIEW_JOIN_REQUESTS,
self::PERMISSION_REVIEW_SUBMISSIONS,
self::PERMISSION_MANAGE_RECRUITMENT,
self::PERMISSION_MANAGE_POSTS,
self::PERMISSION_PUBLISH_POSTS,
self::PERMISSION_PIN_POSTS,
self::PERMISSION_MANAGE_MEMBER_PERMISSIONS,
self::PERMISSION_MANAGE_EVENTS,
self::PERMISSION_MANAGE_CHALLENGES,
self::PERMISSION_MANAGE_PROJECTS,
self::PERMISSION_MANAGE_RELEASES,
self::PERMISSION_PUBLISH_RELEASES,
self::PERMISSION_MANAGE_MILESTONES,
self::PERMISSION_VIEW_REPUTATION_DASHBOARD,
self::PERMISSION_MANAGE_BADGES,
self::PERMISSION_VIEW_INTERNAL_TRUST_METRICS,
self::PERMISSION_FEATURE_RELEASES,
self::PERMISSION_ASSIGN_RELEASE_LEAD,
self::PERMISSION_MANAGE_ASSETS,
self::PERMISSION_FEATURE_CHALLENGE_ENTRIES,
self::PERMISSION_PUBLISH_EVENT_UPDATES,
self::PERMISSION_ATTACH_ASSETS_TO_PROJECTS,
self::PERMISSION_VIEW_INTERNAL_ASSETS,
self::PERMISSION_MANAGE_ACTIVITY_PINS,
];
}
public function canManageCollections(User $user): bool
{
return $this->isOperational() && $this->canPublishArtworks($user);
}
public function avatarUrl(): ?string
{
return $this->assetUrl($this->avatar_path);
}
public function bannerUrl(): ?string
{
return $this->assetUrl($this->banner_path);
}
public function publicUrl(): string
{
return route('groups.show', ['group' => $this->slug]);
}
public function shouldBeSearchable(): bool
{
return $this->visibility === self::VISIBILITY_PUBLIC && $this->status === self::LIFECYCLE_ACTIVE;
}
public function toSearchableArray(): array
{
$recruitment = $this->relationLoaded('recruitmentProfile')
? $this->recruitmentProfile
: $this->recruitmentProfile()->first();
$memberNames = $this->members()
->with('user:id,name,username')
->where('status', self::STATUS_ACTIVE)
->limit(12)
->get()
->map(fn (GroupMember $member): string => (string) ($member->user?->name ?: $member->user?->username ?: ''))
->filter()
->values()
->all();
return [
'id' => (int) $this->id,
'name' => (string) $this->name,
'slug' => (string) $this->slug,
'headline' => (string) ($this->headline ?? ''),
'bio' => (string) ($this->bio ?? ''),
'type' => (string) ($this->type ?? ''),
'visibility' => (string) $this->visibility,
'status' => (string) ($this->status ?? self::LIFECYCLE_ACTIVE),
'artworks_count' => (int) ($this->artworks_count ?? 0),
'followers_count' => (int) ($this->followers_count ?? 0),
'is_recruiting' => (bool) ($recruitment?->is_recruiting ?? false),
'recruitment_headline' => (string) ($recruitment?->headline ?? ''),
'recruitment_roles' => array_values(array_filter($recruitment?->roles_json ?? [])),
'recruitment_skills' => array_values(array_filter($recruitment?->skills_json ?? [])),
'release_titles' => $this->releases()->where('visibility', GroupRelease::VISIBILITY_PUBLIC)->latest('published_at')->limit(6)->pluck('title')->filter()->values()->all(),
'project_titles' => $this->projects()->where('visibility', GroupProject::VISIBILITY_PUBLIC)->latest('updated_at')->limit(6)->pluck('title')->filter()->values()->all(),
'challenge_titles' => $this->challenges()->where('visibility', GroupChallenge::VISIBILITY_PUBLIC)->latest('updated_at')->limit(6)->pluck('title')->filter()->values()->all(),
'event_titles' => $this->events()->where('visibility', GroupEvent::VISIBILITY_PUBLIC)->latest('start_at')->limit(6)->pluck('title')->filter()->values()->all(),
'badge_keys' => $this->badges()->latest('awarded_at')->limit(6)->pluck('badge_key')->filter()->values()->all(),
'member_names' => $memberNames,
];
}
private function assetUrl(?string $path): ?string
{
$trimmed = trim((string) $path);
if ($trimmed === '') {
return null;
}
if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) {
return $trimmed;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($trimmed, '/');
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupActivityItem extends Model
{
use HasFactory;
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_INTERNAL = 'internal';
protected $fillable = [
'group_id',
'type',
'visibility',
'actor_user_id',
'subject_type',
'subject_id',
'headline',
'summary',
'is_pinned',
'occurred_at',
];
protected $casts = [
'is_pinned' => 'boolean',
'occurred_at' => 'datetime',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_user_id');
}
}

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

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class GroupAsset extends Model
{
use HasFactory;
use SoftDeletes;
public const CATEGORY_LOGO = 'logo';
public const CATEGORY_BRAND = 'brand';
public const CATEGORY_PALETTE = 'palette';
public const CATEGORY_WATERMARK = 'watermark';
public const CATEGORY_TEMPLATE = 'template';
public const CATEGORY_REFERENCE = 'reference';
public const CATEGORY_SOURCE_PACK = 'source_pack';
public const CATEGORY_PROMO = 'promo';
public const CATEGORY_MISC = 'misc';
public const VISIBILITY_INTERNAL = 'internal';
public const VISIBILITY_MEMBERS_ONLY = 'members_only';
public const VISIBILITY_PUBLIC_DOWNLOAD = 'public_download';
public const STATUS_ACTIVE = 'active';
public const STATUS_ARCHIVED = 'archived';
protected $fillable = [
'group_id',
'title',
'description',
'category',
'file_path',
'preview_path',
'visibility',
'status',
'linked_project_id',
'uploaded_by_user_id',
'approved_by_user_id',
'is_featured',
'file_meta_json',
];
protected $casts = [
'is_featured' => 'boolean',
'file_meta_json' => 'array',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function linkedProject(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'linked_project_id');
}
public function uploader(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by_user_id');
}
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by_user_id');
}
public function canBeViewedBy(?User $viewer): bool
{
if ($this->status !== self::STATUS_ACTIVE) {
return false;
}
return match ($this->visibility) {
self::VISIBILITY_PUBLIC_DOWNLOAD => $this->group->canBeViewedBy($viewer),
self::VISIBILITY_MEMBERS_ONLY => $viewer !== null && $this->group->hasActiveMember($viewer),
default => $viewer !== null && $this->group->canViewInternalAssets($viewer),
};
}
}

31
app/Models/GroupBadge.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupBadge extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'badge_key',
'awarded_at',
'meta_json',
];
protected $casts = [
'awarded_at' => 'datetime',
'meta_json' => 'array',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Models;
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\SoftDeletes;
class GroupChallenge extends Model
{
use HasFactory;
use SoftDeletes;
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_UNLISTED = 'unlisted';
public const VISIBILITY_PRIVATE = 'private';
public const PARTICIPATION_GROUP_ONLY = 'group_only';
public const PARTICIPATION_INVITE_ONLY = 'invite_only';
public const PARTICIPATION_PUBLIC = 'public';
public const STATUS_DRAFT = 'draft';
public const STATUS_PUBLISHED = 'published';
public const STATUS_ACTIVE = 'active';
public const STATUS_ENDED = 'ended';
public const STATUS_ARCHIVED = 'archived';
protected $fillable = [
'group_id',
'title',
'slug',
'summary',
'description',
'cover_path',
'visibility',
'participation_scope',
'status',
'start_at',
'end_at',
'rules_text',
'submission_instructions',
'judging_mode',
'linked_collection_id',
'linked_project_id',
'created_by_user_id',
'featured_artwork_id',
];
protected $casts = [
'start_at' => 'datetime',
'end_at' => 'datetime',
];
public function getRouteKeyName(): string
{
return 'slug';
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function linkedCollection(): BelongsTo
{
return $this->belongsTo(Collection::class, 'linked_collection_id');
}
public function linkedProject(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'linked_project_id');
}
public function featuredArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'featured_artwork_id');
}
public function artworkLinks(): HasMany
{
return $this->hasMany(GroupChallengeArtwork::class);
}
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'group_challenge_artworks')
->withPivot(['submitted_by_user_id', 'sort_order'])
->withTimestamps()
->orderBy('group_challenge_artworks.sort_order');
}
public function canBeViewedBy(?User $viewer): bool
{
if ($this->visibility !== self::VISIBILITY_PRIVATE) {
return $this->group->canBeViewedBy($viewer);
}
return $viewer !== null && $this->group->canViewStudio($viewer);
}
public function coverUrl(): ?string
{
$path = trim((string) $this->cover_path);
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupChallengeArtwork extends Model
{
use HasFactory;
protected $fillable = [
'group_challenge_id',
'artwork_id',
'submitted_by_user_id',
'sort_order',
];
public function challenge(): BelongsTo
{
return $this->belongsTo(GroupChallenge::class, 'group_challenge_id');
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function submitter(): BelongsTo
{
return $this->belongsTo(User::class, 'submitted_by_user_id');
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupContributorStat extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'user_id',
'credited_artworks_count',
'release_count',
'project_count',
'review_actions_count',
'approved_submissions_count',
'reputation_meta_json',
];
protected $casts = [
'credited_artworks_count' => 'integer',
'release_count' => 'integer',
'project_count' => 'integer',
'review_actions_count' => 'integer',
'approved_submissions_count' => 'integer',
'reputation_meta_json' => 'array',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupDiscoveryMetric extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'freshness_score',
'activity_score',
'release_score',
'trust_score',
'collaboration_score',
'last_calculated_at',
];
protected $casts = [
'freshness_score' => 'float',
'activity_score' => 'float',
'release_score' => 'float',
'trust_score' => 'float',
'collaboration_score' => 'float',
'last_calculated_at' => 'datetime',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
}

118
app/Models/GroupEvent.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class GroupEvent extends Model
{
use HasFactory;
use SoftDeletes;
public const TYPE_LAUNCH = 'launch';
public const TYPE_CHALLENGE = 'challenge';
public const TYPE_LIVESTREAM = 'livestream';
public const TYPE_MEETUP = 'meetup';
public const TYPE_MILESTONE = 'milestone';
public const TYPE_SHOWCASE = 'showcase';
public const TYPE_INTERNAL_SESSION = 'internal_session';
public const TYPE_RELEASE_WINDOW = 'release_window';
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_MEMBERS_ONLY = 'members_only';
public const VISIBILITY_PRIVATE = 'private';
public const STATUS_DRAFT = 'draft';
public const STATUS_PUBLISHED = 'published';
public const STATUS_ARCHIVED = 'archived';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'group_id',
'title',
'slug',
'summary',
'description',
'event_type',
'visibility',
'start_at',
'end_at',
'timezone',
'cover_path',
'location',
'external_url',
'linked_project_id',
'linked_collection_id',
'linked_challenge_id',
'status',
'is_featured',
'created_by_user_id',
'published_at',
];
protected $casts = [
'start_at' => 'datetime',
'end_at' => 'datetime',
'is_featured' => 'boolean',
'published_at' => 'datetime',
];
public function getRouteKeyName(): string
{
return 'slug';
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function linkedProject(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'linked_project_id');
}
public function linkedCollection(): BelongsTo
{
return $this->belongsTo(Collection::class, 'linked_collection_id');
}
public function linkedChallenge(): BelongsTo
{
return $this->belongsTo(GroupChallenge::class, 'linked_challenge_id');
}
public function canBeViewedBy(?User $viewer): bool
{
return match ($this->visibility) {
self::VISIBILITY_PUBLIC => $this->group->canBeViewedBy($viewer),
self::VISIBILITY_MEMBERS_ONLY => $viewer !== null && $this->group->hasActiveMember($viewer),
default => $viewer !== null && $this->group->canViewStudio($viewer),
};
}
public function coverUrl(): ?string
{
$path = trim((string) $this->cover_path);
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupFollow extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'user_id',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupHistory extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'group_id',
'actor_user_id',
'action_type',
'target_type',
'target_id',
'summary',
'before_json',
'after_json',
'created_at',
];
protected $casts = [
'before_json' => 'array',
'after_json' => 'array',
'created_at' => 'datetime',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_user_id');
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupInvitation extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_DECLINED = 'declined';
public const STATUS_REVOKED = 'revoked';
public const STATUS_EXPIRED = 'expired';
protected $fillable = [
'group_id',
'invited_user_id',
'invited_by_user_id',
'source_group_member_id',
'role',
'status',
'token',
'note',
'invited_at',
'expires_at',
'responded_at',
'accepted_at',
'revoked_at',
];
protected $casts = [
'invited_at' => 'datetime',
'expires_at' => 'datetime',
'responded_at' => 'datetime',
'accepted_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function getRouteKeyName(): string
{
return 'token';
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function invitedUser(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_user_id');
}
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by_user_id');
}
public function sourceGroupMember(): BelongsTo
{
return $this->belongsTo(GroupMember::class, 'source_group_member_id');
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupJoinRequest extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
public const STATUS_WITHDRAWN = 'withdrawn';
public const STATUS_EXPIRED = 'expired';
protected $fillable = [
'group_id',
'user_id',
'message',
'portfolio_url',
'desired_role',
'skills_json',
'status',
'reviewed_by_user_id',
'review_notes',
'reviewed_at',
'expires_at',
];
protected $casts = [
'skills_json' => 'array',
'reviewed_at' => 'datetime',
'expires_at' => 'datetime',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function reviewedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by_user_id');
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupMember extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'user_id',
'invited_by_user_id',
'role',
'status',
'permission_overrides_json',
'note',
'invited_at',
'expires_at',
'accepted_at',
'revoked_at',
];
protected $casts = [
'permission_overrides_json' => 'array',
'invited_at' => 'datetime',
'expires_at' => 'datetime',
'accepted_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by_user_id');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupMemberBadge extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'user_id',
'badge_key',
'awarded_at',
'meta_json',
];
protected $casts = [
'awarded_at' => 'datetime',
'meta_json' => 'array',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

59
app/Models/GroupPost.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class GroupPost extends Model
{
use HasFactory;
use SoftDeletes;
public const TYPE_ANNOUNCEMENT = 'announcement';
public const TYPE_RELEASE = 'release';
public const TYPE_RECRUITMENT = 'recruitment';
public const TYPE_UPDATE = 'update';
public const STATUS_DRAFT = 'draft';
public const STATUS_PUBLISHED = 'published';
public const STATUS_ARCHIVED = 'archived';
protected $fillable = [
'group_id',
'author_user_id',
'type',
'title',
'slug',
'excerpt',
'content',
'cover_path',
'status',
'is_pinned',
'published_at',
];
protected $casts = [
'is_pinned' => 'boolean',
'published_at' => 'datetime',
];
public function getRouteKeyName(): string
{
return 'slug';
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_user_id');
}
}

162
app/Models/GroupProject.php Normal file
View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Models;
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\SoftDeletes;
class GroupProject extends Model
{
use HasFactory;
use SoftDeletes;
public const STATUS_PLANNED = 'planned';
public const STATUS_ACTIVE = 'active';
public const STATUS_REVIEW = 'review';
public const STATUS_RELEASED = 'released';
public const STATUS_ARCHIVED = 'archived';
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_UNLISTED = 'unlisted';
public const VISIBILITY_PRIVATE = 'private';
protected $fillable = [
'group_id',
'title',
'slug',
'summary',
'description',
'cover_path',
'status',
'visibility',
'start_date',
'target_date',
'released_at',
'created_by_user_id',
'lead_user_id',
'linked_collection_id',
'linked_featured_artwork_id',
'pinned_post_id',
];
protected $casts = [
'start_date' => 'date',
'target_date' => 'date',
'released_at' => 'datetime',
];
public function getRouteKeyName(): string
{
return 'slug';
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function lead(): BelongsTo
{
return $this->belongsTo(User::class, 'lead_user_id');
}
public function linkedCollection(): BelongsTo
{
return $this->belongsTo(Collection::class, 'linked_collection_id');
}
public function featuredArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'linked_featured_artwork_id');
}
public function pinnedPost(): BelongsTo
{
return $this->belongsTo(GroupPost::class, 'pinned_post_id');
}
public function artworkLinks(): HasMany
{
return $this->hasMany(GroupProjectArtwork::class);
}
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'group_project_artworks')
->withPivot(['sort_order'])
->withTimestamps()
->orderBy('group_project_artworks.sort_order');
}
public function memberLinks(): HasMany
{
return $this->hasMany(GroupProjectMember::class);
}
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class, 'group_project_members')
->withPivot(['role_label', 'is_lead'])
->withTimestamps();
}
public function assets(): HasMany
{
return $this->hasMany(GroupAsset::class, 'linked_project_id');
}
public function milestones(): HasMany
{
return $this->hasMany(GroupProjectMilestone::class)->orderBy('sort_order');
}
public function releases(): HasMany
{
return $this->hasMany(GroupRelease::class, 'linked_project_id');
}
public function challenges(): HasMany
{
return $this->hasMany(GroupChallenge::class, 'linked_project_id');
}
public function events(): HasMany
{
return $this->hasMany(GroupEvent::class, 'linked_project_id');
}
public function canBeViewedBy(?User $viewer): bool
{
if ($this->visibility !== self::VISIBILITY_PRIVATE) {
return $this->group->canBeViewedBy($viewer);
}
return $viewer !== null && $this->group->canViewStudio($viewer);
}
public function coverUrl(): ?string
{
$path = trim((string) $this->cover_path);
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupProjectArtwork extends Model
{
use HasFactory;
protected $fillable = [
'group_project_id',
'artwork_id',
'sort_order',
];
public function project(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'group_project_id');
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupProjectMember extends Model
{
use HasFactory;
protected $fillable = [
'group_project_id',
'user_id',
'role_label',
'is_lead',
];
protected $casts = [
'is_lead' => 'boolean',
];
public function project(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'group_project_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupProjectMilestone extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_BLOCKED = 'blocked';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'group_project_id',
'title',
'summary',
'status',
'due_date',
'owner_user_id',
'sort_order',
'notes',
];
protected $casts = [
'due_date' => 'date',
];
public function project(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'group_project_id');
}
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupRecruitmentProfile extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'is_recruiting',
'headline',
'description',
'roles_json',
'skills_json',
'contact_mode',
'visibility',
];
protected $casts = [
'is_recruiting' => 'boolean',
'roles_json' => 'array',
'skills_json' => 'array',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
}

156
app/Models/GroupRelease.php Normal file
View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Models;
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\SoftDeletes;
class GroupRelease extends Model
{
use HasFactory;
use SoftDeletes;
public const STATUS_PLANNED = 'planned';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_INTERNAL_REVIEW = 'internal_review';
public const STATUS_SCHEDULED = 'scheduled';
public const STATUS_RELEASED = 'released';
public const STATUS_ARCHIVED = 'archived';
public const STATUS_CANCELLED = 'cancelled';
public const STAGE_CONCEPT = 'concept';
public const STAGE_PRODUCTION = 'production';
public const STAGE_REVIEW = 'review';
public const STAGE_PACKAGING = 'packaging';
public const STAGE_APPROVAL = 'approval';
public const STAGE_PUBLISHING = 'publishing';
public const STAGE_RELEASED = 'released';
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_UNLISTED = 'unlisted';
public const VISIBILITY_PRIVATE = 'private';
protected $fillable = [
'group_id',
'title',
'slug',
'summary',
'description',
'cover_path',
'status',
'current_stage',
'visibility',
'planned_release_at',
'released_at',
'lead_user_id',
'linked_project_id',
'linked_collection_id',
'featured_artwork_id',
'release_notes',
'created_by_user_id',
'published_at',
'is_featured',
];
protected $casts = [
'planned_release_at' => 'datetime',
'released_at' => 'datetime',
'published_at' => 'datetime',
'is_featured' => 'boolean',
];
public function getRouteKeyName(): string
{
return 'slug';
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function lead(): BelongsTo
{
return $this->belongsTo(User::class, 'lead_user_id');
}
public function linkedProject(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'linked_project_id');
}
public function linkedCollection(): BelongsTo
{
return $this->belongsTo(Collection::class, 'linked_collection_id');
}
public function featuredArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'featured_artwork_id');
}
public function artworkLinks(): HasMany
{
return $this->hasMany(GroupReleaseArtwork::class)->orderBy('sort_order');
}
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'group_release_artworks')
->withPivot(['sort_order'])
->withTimestamps()
->orderBy('group_release_artworks.sort_order');
}
public function contributorLinks(): HasMany
{
return $this->hasMany(GroupReleaseContributor::class)->orderBy('sort_order');
}
public function contributors(): BelongsToMany
{
return $this->belongsToMany(User::class, 'group_release_contributors')
->withPivot(['role_label', 'sort_order'])
->withTimestamps();
}
public function milestones(): HasMany
{
return $this->hasMany(GroupReleaseMilestone::class)->orderBy('sort_order');
}
public function canBeViewedBy(?User $viewer): bool
{
if ($this->visibility !== self::VISIBILITY_PRIVATE) {
return $this->group->canBeViewedBy($viewer);
}
return $viewer !== null && $this->group->canViewStudio($viewer);
}
public function coverUrl(): ?string
{
$path = trim((string) $this->cover_path);
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupReleaseArtwork extends Model
{
use HasFactory;
protected $fillable = [
'group_release_id',
'artwork_id',
'sort_order',
];
public function release(): BelongsTo
{
return $this->belongsTo(GroupRelease::class, 'group_release_id');
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupReleaseContributor extends Model
{
use HasFactory;
protected $fillable = [
'group_release_id',
'user_id',
'role_label',
'sort_order',
];
public function release(): BelongsTo
{
return $this->belongsTo(GroupRelease::class, 'group_release_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GroupReleaseMilestone extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_BLOCKED = 'blocked';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'group_release_id',
'title',
'summary',
'status',
'due_date',
'owner_user_id',
'sort_order',
'notes',
];
protected $casts = [
'due_date' => 'date',
];
public function release(): BelongsTo
{
return $this->belongsTo(GroupRelease::class, 'group_release_id');
}
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
}

View File

@@ -13,6 +13,7 @@ class Leaderboard extends Model
public const TYPE_CREATOR = 'creator';
public const TYPE_ARTWORK = 'artwork';
public const TYPE_GROUP = 'group';
public const TYPE_STORY = 'story';
public const PERIOD_DAILY = 'daily';

View File

@@ -3,6 +3,10 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Models\Group;
use App\Models\GroupFollow;
use App\Models\GroupInvitation;
use App\Models\GroupMember;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -125,6 +129,26 @@ class User extends Authenticatable
return $this->hasMany(Collection::class)->latest('updated_at');
}
public function ownedGroups(): HasMany
{
return $this->hasMany(Group::class, 'owner_user_id')->latest('updated_at');
}
public function groupMemberships(): HasMany
{
return $this->hasMany(GroupMember::class)->latest('updated_at');
}
public function groupInvitations(): HasMany
{
return $this->hasMany(GroupInvitation::class, 'invited_user_id')->latest('updated_at');
}
public function groupFollows(): HasMany
{
return $this->hasMany(GroupFollow::class)->latest('updated_at');
}
public function savedCollectionLists(): HasMany
{
return $this->hasMany(CollectionSavedList::class, 'user_id')->orderBy('title');