'A file upload is required for group assets.', ]); } $extension = strtolower((string) ($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin')); $filename = (string) Str::uuid() . '.' . $extension; $directory = 'group-assets/' . (int) $group->id; $storedPath = $file->storeAs($directory, $filename, self::STORAGE_DISK); $mime = strtolower((string) ($file->getMimeType() ?: 'application/octet-stream')); $asset = GroupAsset::query()->create([ 'group_id' => (int) $group->id, 'title' => trim((string) $attributes['title']), 'description' => $this->nullableString($attributes['description'] ?? null), 'category' => (string) ($attributes['category'] ?? GroupAsset::CATEGORY_MISC), 'file_path' => (string) $storedPath, 'preview_path' => null, 'visibility' => (string) ($attributes['visibility'] ?? GroupAsset::VISIBILITY_MEMBERS_ONLY), 'status' => (string) ($attributes['status'] ?? GroupAsset::STATUS_ACTIVE), 'linked_project_id' => $this->normalizeProjectId($group, $attributes['linked_project_id'] ?? null), 'uploaded_by_user_id' => (int) $actor->id, 'approved_by_user_id' => $group->canManageAssets($actor) ? (int) $actor->id : null, 'is_featured' => (bool) ($attributes['is_featured'] ?? false), 'file_meta_json' => [ 'original_name' => $file->getClientOriginalName(), 'mime_type' => $mime, 'size' => (int) $file->getSize(), 'extension' => $extension, ], ]); $this->history->record( $group, $actor, 'asset_uploaded', sprintf('Uploaded asset "%s".', $asset->title), 'group_asset', (int) $asset->id, null, $asset->only(['title', 'category', 'visibility', 'status']) ); $this->activity->record( $group, $actor, 'asset_uploaded', 'group_asset', (int) $asset->id, sprintf('%s uploaded a new group asset: %s', $actor->name ?: $actor->username ?: 'A member', $asset->title), $asset->description, $asset->visibility === GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD ? 'public' : 'internal', ); return $asset->fresh(['group', 'uploader.profile', 'approver.profile', 'linkedProject']); } public function update(GroupAsset $asset, User $actor, array $attributes): GroupAsset { $before = $asset->only(['title', 'description', 'category', 'visibility', 'status', 'linked_project_id', 'is_featured']); $wasActive = $asset->status === GroupAsset::STATUS_ACTIVE; $asset->fill([ 'title' => trim((string) ($attributes['title'] ?? $asset->title)), 'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $asset->description, 'category' => (string) ($attributes['category'] ?? $asset->category), 'visibility' => (string) ($attributes['visibility'] ?? $asset->visibility), 'status' => (string) ($attributes['status'] ?? $asset->status), 'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($asset->group, $attributes['linked_project_id']) : $asset->linked_project_id, 'is_featured' => (bool) ($attributes['is_featured'] ?? $asset->is_featured), 'approved_by_user_id' => (string) ($attributes['status'] ?? $asset->status) === GroupAsset::STATUS_ACTIVE ? (int) $actor->id : $asset->approved_by_user_id, ])->save(); if (! $wasActive && $asset->status === GroupAsset::STATUS_ACTIVE && $asset->uploader && (int) $asset->uploader->id !== (int) $actor->id) { app(NotificationService::class)->notifyGroupAssetApproved($asset->uploader, $actor, $asset->group, $asset); } $this->history->record( $asset->group, $actor, 'asset_updated', sprintf('Updated asset "%s".', $asset->title), 'group_asset', (int) $asset->id, $before, $asset->only(['title', 'description', 'category', 'visibility', 'status', 'linked_project_id', 'is_featured']) ); return $asset->fresh(['group', 'uploader.profile', 'approver.profile', 'linkedProject']); } public function studioListing(Group $group, User $viewer, array $filters = []): array { $bucket = (string) ($filters['bucket'] ?? 'all'); $category = (string) ($filters['category'] ?? 'all'); $search = trim((string) ($filters['q'] ?? '')); $page = max(1, (int) ($filters['page'] ?? 1)); $perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50); $query = GroupAsset::query() ->with(['uploader.profile', 'approver.profile', 'linkedProject']) ->where('group_id', $group->id); if ($bucket !== 'all') { $query->where('visibility', $bucket); } if ($category !== 'all') { $query->where('category', $category); } if ($search !== '') { $query->where(function ($builder) use ($search): void { $builder->where('title', 'like', '%' . $search . '%') ->orWhere('description', 'like', '%' . $search . '%') ->orWhere('file_meta_json->original_name', 'like', '%' . $search . '%'); }); } if (! $group->canViewInternalAssets($viewer)) { $query->whereIn('visibility', [GroupAsset::VISIBILITY_MEMBERS_ONLY, GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD]); } $paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page); return [ 'items' => collect($paginator->items())->map(fn (GroupAsset $asset): array => $this->mapStudioAsset($asset))->values()->all(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], 'filters' => [ 'bucket' => $bucket, 'category' => $category, 'q' => $search, ], 'bucket_options' => [ ['value' => 'all', 'label' => 'All'], ['value' => GroupAsset::VISIBILITY_INTERNAL, 'label' => 'Internal'], ['value' => GroupAsset::VISIBILITY_MEMBERS_ONLY, 'label' => 'Members only'], ['value' => GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD, 'label' => 'Public download'], ], ]; } public function publicListing(Group $group, int $limit = 12): array { return GroupAsset::query() ->with(['uploader.profile', 'linkedProject']) ->where('group_id', $group->id) ->where('status', GroupAsset::STATUS_ACTIVE) ->where('visibility', GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD) ->latest('updated_at') ->limit($limit) ->get() ->map(fn (GroupAsset $asset): array => $this->mapPublicAsset($asset)) ->values() ->all(); } public function downloadResponse(GroupAsset $asset): StreamedResponse { $name = (string) ($asset->file_meta_json['original_name'] ?? basename((string) $asset->file_path)); $mime = (string) ($asset->file_meta_json['mime_type'] ?? 'application/octet-stream'); return Storage::disk(self::STORAGE_DISK)->download((string) $asset->file_path, $name, [ 'Content-Type' => $mime, ]); } public function mapStudioAsset(GroupAsset $asset): array { return [ 'id' => (int) $asset->id, 'title' => (string) $asset->title, 'description' => $asset->description, 'category' => (string) $asset->category, 'visibility' => (string) $asset->visibility, 'status' => (string) $asset->status, 'is_featured' => (bool) $asset->is_featured, 'file_meta' => $asset->file_meta_json ?? [], 'linked_project' => $asset->linkedProject ? ['id' => (int) $asset->linkedProject->id, 'title' => $asset->linkedProject->title] : null, 'download_url' => route('groups.assets.download', ['group' => $asset->group, 'asset' => $asset]), 'urls' => [ 'edit' => route('studio.groups.assets.update', ['group' => $asset->group, 'asset' => $asset]), ], ]; } public function mapPublicAsset(GroupAsset $asset): array { return [ 'id' => (int) $asset->id, 'title' => (string) $asset->title, 'description' => $asset->description, 'category' => (string) $asset->category, 'download_url' => route('groups.assets.download', ['group' => $asset->group, 'asset' => $asset]), ]; } private function normalizeProjectId(Group $group, mixed $projectId): ?int { $id = (int) $projectId; return $id > 0 && $group->projects()->where('id', $id)->exists() ? $id : null; } private function nullableString(mixed $value): ?string { $trimmed = trim((string) $value); return $trimmed !== '' ? $trimmed : null; } }