Enforce task limits and update event form
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-21 09:49:30 +01:00
parent 0b1430e64d
commit 1c5412e82c
15 changed files with 491 additions and 52 deletions

View File

@@ -110,6 +110,7 @@ class TaskCollectionController extends Controller
),
'created_task_ids' => $result['created_task_ids'],
'attached_task_ids' => $result['attached_task_ids'],
'skipped_task_ids' => $result['skipped_task_ids'],
]);
}

View File

@@ -10,6 +10,7 @@ use App\Models\Event;
use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use App\Support\TenantRequestResolver;
@@ -20,6 +21,8 @@ use Symfony\Component\HttpFoundation\Response;
class TaskController extends Controller
{
public function __construct(private readonly PackageLimitEvaluator $packageLimitEvaluator) {}
/**
* Display a listing of the tenant's tasks.
*/
@@ -163,7 +166,8 @@ class TaskController extends Controller
{
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id;
$tenant = $this->currentTenant($request);
$tenantId = $tenant->id;
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
abort(404);
@@ -173,6 +177,11 @@ class TaskController extends Controller
return response()->json(['message' => 'Task ist bereits diesem Event zugewiesen.'], 409);
}
$limitStatus = $this->resolveTaskLimitStatus($event, $tenant);
if ($limitStatus['remaining'] !== null && $limitStatus['remaining'] <= 0) {
return $this->taskLimitExceededResponse($event, $limitStatus);
}
$task->assignedEvents()->attach($event->id);
return response()->json([
@@ -187,7 +196,8 @@ class TaskController extends Controller
{
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id;
$tenant = $this->currentTenant($request);
$tenantId = $tenant->id;
if ($event->tenant_id !== $tenantId) {
abort(404);
@@ -203,12 +213,27 @@ class TaskController extends Controller
);
}
$taskIds = array_values(array_unique(array_map('intval', $taskIds)));
$tasks = Task::whereIn('id', $taskIds)
->where(function ($query) use ($tenantId) {
$query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
})
->get();
$assignedIds = $event->tasks()
->whereIn('tasks.id', $taskIds)
->pluck('tasks.id')
->all();
$pendingIds = array_values(array_diff($taskIds, $assignedIds));
$limitStatus = $this->resolveTaskLimitStatus($event, $tenant);
if (
$limitStatus['remaining'] !== null
&& $pendingIds !== []
&& $limitStatus['remaining'] < count($pendingIds)
) {
return $this->taskLimitExceededResponse($event, $limitStatus);
}
$attached = 0;
foreach ($tasks as $task) {
if (! $task->assignedEvents()->where('event_id', $event->id)->exists()) {
@@ -330,6 +355,52 @@ class TaskController extends Controller
return TenantRequestResolver::resolve($request);
}
/**
* @return array{limit: ?int, used: int, remaining: ?int, package_id: ?int}
*/
protected function resolveTaskLimitStatus(Event $event, Tenant $tenant): array
{
$event->loadMissing(['eventPackage.package', 'eventPackages.package']);
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
$tenant,
$event->id,
$event
);
$limit = $eventPackage?->effectiveLimits()['max_tasks'] ?? null;
$used = $event->tasks()->count();
$remaining = $limit === null ? null : max(0, (int) $limit - $used);
return [
'limit' => $limit === null ? null : (int) $limit,
'used' => $used,
'remaining' => $remaining,
'package_id' => $eventPackage?->package_id,
];
}
/**
* @param array{limit: ?int, used: int, remaining: ?int, package_id: ?int} $limitStatus
*/
protected function taskLimitExceededResponse(Event $event, array $limitStatus): JsonResponse
{
return ApiError::response(
'task_limit_exceeded',
__('api.packages.task_limit_exceeded.title'),
__('api.packages.task_limit_exceeded.message'),
Response::HTTP_PAYMENT_REQUIRED,
[
'scope' => 'tasks',
'used' => $limitStatus['used'],
'limit' => $limitStatus['limit'],
'remaining' => $limitStatus['remaining'] ?? 0,
'event_id' => $event->id,
'package_id' => $limitStatus['package_id'],
]
);
}
protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array
{
if (array_key_exists('title', $data)) {

View File

@@ -97,13 +97,26 @@ class EventResource extends JsonResource
'watermark_allowed' => (bool) optional($eventPackage->package)->watermark_allowed,
] : null,
'limits' => $eventPackage && $limitEvaluator
? $limitEvaluator->summarizeEventPackage($eventPackage)
? $limitEvaluator->summarizeEventPackage($eventPackage, $this->resolveTasksUsed())
: null,
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
'member_permissions' => $memberPermissions,
];
}
protected function resolveTasksUsed(): ?int
{
if (isset($this->tasks_count)) {
return (int) $this->tasks_count;
}
if ($this->relationLoaded('tasks')) {
return $this->tasks->count();
}
return null;
}
/**
* @param array<string, mixed> $settings
* @return array<string, mixed>

View File

@@ -223,7 +223,7 @@ class PackageLimitEvaluator
return $eventPackage;
}
public function summarizeEventPackage(EventPackage $eventPackage): array
public function summarizeEventPackage(EventPackage $eventPackage, ?int $tasksUsed = null): array
{
$limits = $eventPackage->effectiveLimits();
@@ -244,12 +244,22 @@ class PackageLimitEvaluator
config('package-limits.gallery_warning_days', [])
);
$taskSummary = $tasksUsed === null
? null
: $this->buildUsageSummary(
$tasksUsed,
$limits['max_tasks'],
[]
);
return [
'photos' => $photoSummary,
'guests' => $guestSummary,
'gallery' => $gallerySummary,
'tasks' => $taskSummary,
'can_upload_photos' => $photoSummary['state'] !== 'limit_reached' && $gallerySummary['state'] !== 'expired',
'can_add_guests' => $guestSummary['state'] !== 'limit_reached',
'can_add_tasks' => $taskSummary ? $taskSummary['state'] !== 'limit_reached' : null,
];
}

View File

@@ -11,12 +11,10 @@ use RuntimeException;
class TaskCollectionImportService
{
public function __construct(private readonly DatabaseManager $db)
{
}
public function __construct(private readonly DatabaseManager $db) {}
/**
* @return array{collection: TaskCollection, created_task_ids: array<int>, attached_task_ids: array<int>}
* @return array{collection: TaskCollection, created_task_ids: array<int>, attached_task_ids: array<int>, skipped_task_ids: array<int>}
*/
public function import(TaskCollection $collection, Event $event): array
{
@@ -33,8 +31,28 @@ class TaskCollectionImportService
$createdTaskIds = [];
$attachedTaskIds = [];
$skippedTaskIds = [];
$event->loadMissing(['eventPackage.package', 'eventPackages.package']);
$eventPackage = $event->eventPackage;
if (! $eventPackage && method_exists($event, 'eventPackages')) {
$eventPackage = $event->eventPackages()
->with('package')
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->first();
}
$taskLimit = $eventPackage?->effectiveLimits()['max_tasks'] ?? null;
$remaining = $taskLimit === null ? null : max(0, (int) $taskLimit - $event->tasks()->count());
foreach ($collection->tasks as $task) {
if ($remaining !== null && $remaining <= 0) {
$skippedTaskIds[] = $task->id;
continue;
}
$tenantTask = $this->resolveTenantTask($task, $targetCollection, $tenantId);
if ($tenantTask->wasRecentlyCreated) {
@@ -44,6 +62,9 @@ class TaskCollectionImportService
if (! $tenantTask->assignedEvents()->where('event_id', $event->id)->exists()) {
$tenantTask->assignedEvents()->attach($event->id);
$attachedTaskIds[] = $tenantTask->id;
if ($remaining !== null) {
$remaining = max(0, $remaining - 1);
}
}
}
@@ -55,6 +76,7 @@ class TaskCollectionImportService
'collection' => $targetCollection->fresh(),
'created_task_ids' => $createdTaskIds,
'attached_task_ids' => $attachedTaskIds,
'skipped_task_ids' => $skippedTaskIds,
];
});
}
@@ -139,10 +161,10 @@ class TaskCollectionImportService
protected function buildCollectionSlug(?string $slug, int $tenantId): string
{
$base = Str::slug(($slug ?: 'collection') . '-' . $tenantId);
$base = Str::slug(($slug ?: 'collection').'-'.$tenantId);
do {
$candidate = $base . '-' . Str::random(4);
$candidate = $base.'-'.Str::random(4);
} while (TaskCollection::where('slug', $candidate)->exists());
return $candidate;
@@ -153,7 +175,7 @@ class TaskCollectionImportService
$slugBase = Str::slug($base) ?: 'task';
do {
$candidate = $slugBase . '-' . Str::random(6);
$candidate = $slugBase.'-'.Str::random(6);
} while (Task::where('slug', $candidate)->exists());
return $candidate;