Files
2026-04-18 17:02:56 +02:00

243 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Group;
use App\Models\GroupAsset;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class GroupAssetService
{
private const STORAGE_DISK = 'local';
public function __construct(
private readonly GroupHistoryService $history,
private readonly GroupActivityService $activity,
) {
}
public function store(Group $group, User $actor, array $attributes): GroupAsset
{
$file = $attributes['file'];
if (! $file instanceof UploadedFile) {
throw ValidationException::withMessages([
'file' => '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;
}
}