246 خطوط
10 KiB
PHP
246 خطوط
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\TaskStatus;
|
|
use App\Exceptions\TaskVersionConflictException;
|
|
use App\Models\Task;
|
|
use App\Models\User;
|
|
use App\Notifications\TaskAssignedNotification;
|
|
use App\Support\AccessControl;
|
|
use App\Support\EntityResolver;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class TaskService
|
|
{
|
|
public function create(array $data, User $actor): Task
|
|
{
|
|
$entity = null;
|
|
if (! empty($data['taskable_type'])) {
|
|
$entity = EntityResolver::resolve($data['taskable_type'], (int) $data['taskable_id']);
|
|
abort_unless(AccessControl::canAccessEntity($actor, $entity), 403, 'به موجودیت مرتبط دسترسی ندارید.');
|
|
}
|
|
|
|
$assigneeId = (int) ($data['assigned_to'] ?? $actor->id);
|
|
$this->assertAssignable($actor, $assigneeId, $assigneeId !== $actor->id);
|
|
|
|
if (! empty($data['parent_task_id'])) {
|
|
$parent = Task::findOrFail($data['parent_task_id']);
|
|
Gate::forUser($actor)->authorize('view', $parent);
|
|
}
|
|
|
|
$task = DB::transaction(function () use ($data, $actor, $entity, $assigneeId): Task {
|
|
$task = Task::create([
|
|
...Arr::only($data, ['subject', 'description', 'priority', 'due_at', 'reminder_at', 'parent_task_id', 'estimated_minutes', 'visibility']),
|
|
'taskable_type' => $entity ? $entity::class : null,
|
|
'taskable_id' => $entity?->getKey(),
|
|
'assigned_to' => $assigneeId,
|
|
'assigned_by' => $actor->id,
|
|
'created_by' => $actor->id,
|
|
'status' => TaskStatus::Open->value,
|
|
'version' => 1,
|
|
]);
|
|
|
|
ActivityLogger::log('task_created', "Task {$task->id} created", $task, null, $this->auditState($task));
|
|
|
|
return $task;
|
|
});
|
|
|
|
try {
|
|
TaskAssignedNotification::send($task, $actor);
|
|
} catch (\Throwable) {
|
|
// Notification failure must not roll back task creation.
|
|
}
|
|
|
|
return $this->load($task);
|
|
}
|
|
|
|
public function update(Task $task, array $data, User $actor): Task
|
|
{
|
|
return DB::transaction(function () use ($task, $data): Task {
|
|
$locked = $this->locked($task, (int) $data['version']);
|
|
$before = $this->auditState($locked);
|
|
$locked->fill(Arr::only($data, ['subject', 'description', 'priority', 'due_at', 'reminder_at', 'estimated_minutes', 'visibility']));
|
|
$locked->version++;
|
|
$locked->save();
|
|
ActivityLogger::log('task_updated', "Task {$locked->id} updated", $locked, $before, $this->auditState($locked));
|
|
|
|
return $this->load($locked);
|
|
});
|
|
}
|
|
|
|
public function assign(Task $task, int $assigneeId, int $version, User $actor): Task
|
|
{
|
|
$this->assertAssignable($actor, $assigneeId, true);
|
|
$reassigned = $task->assigned_to !== null && $task->assigned_to !== $assigneeId;
|
|
|
|
$updated = DB::transaction(function () use ($task, $assigneeId, $version, $actor, $reassigned): Task {
|
|
$locked = $this->locked($task, $version);
|
|
Gate::forUser($actor)->authorize('assign', [$locked, $assigneeId]);
|
|
$before = $this->auditState($locked);
|
|
$locked->update([
|
|
'assigned_to' => $assigneeId,
|
|
'assigned_by' => $actor->id,
|
|
'version' => $locked->version + 1,
|
|
]);
|
|
ActivityLogger::log($reassigned ? 'task_reassigned' : 'task_assigned', "Task {$locked->id} assignment changed", $locked, $before, $this->auditState($locked));
|
|
|
|
return $this->load($locked);
|
|
});
|
|
|
|
try {
|
|
TaskAssignedNotification::send($updated, $actor, $reassigned);
|
|
} catch (\Throwable) {
|
|
// Notification failure must not roll back assignment.
|
|
}
|
|
|
|
return $updated;
|
|
}
|
|
|
|
public function transition(Task $task, TaskStatus $target, int $version, User $actor): Task
|
|
{
|
|
return DB::transaction(function () use ($task, $target, $version, $actor): Task {
|
|
$locked = $this->locked($task, $version);
|
|
Gate::forUser($actor)->authorize('transition', $locked);
|
|
$this->assertTransition($locked, $target);
|
|
$before = $this->auditState($locked);
|
|
|
|
$attributes = ['status' => $target->value, 'version' => $locked->version + 1];
|
|
if ($target === TaskStatus::InProgress) {
|
|
$attributes['started_at'] = $locked->started_at ?? now();
|
|
$attributes['completed_at'] = null;
|
|
} elseif ($target === TaskStatus::Done) {
|
|
$attributes['completed_at'] = now();
|
|
} elseif ($target === TaskStatus::Open) {
|
|
$attributes['completed_at'] = null;
|
|
} elseif ($target === TaskStatus::Cancelled) {
|
|
$attributes['completed_at'] = null;
|
|
}
|
|
|
|
$locked->update($attributes);
|
|
ActivityLogger::log('task_'.$target->value, "Task {$locked->id} status changed", $locked, $before, $this->auditState($locked));
|
|
|
|
return $this->load($locked);
|
|
});
|
|
}
|
|
|
|
public function bulkAssign(array $ids, int $assigneeId, User $actor): array
|
|
{
|
|
Gate::forUser($actor)->authorize('bulkManage', Task::class);
|
|
$this->assertAssignable($actor, $assigneeId, true);
|
|
|
|
$tasks = DB::transaction(function () use ($ids, $assigneeId, $actor) {
|
|
$tasks = $this->bulkTasks($ids, $actor, 'assign', $assigneeId);
|
|
foreach ($tasks as $task) {
|
|
$before = $this->auditState($task);
|
|
$task->update(['assigned_to' => $assigneeId, 'assigned_by' => $actor->id, 'version' => $task->version + 1]);
|
|
ActivityLogger::log('task_bulk_assigned', "Task {$task->id} bulk assigned", $task, $before, $this->auditState($task));
|
|
}
|
|
|
|
return $tasks;
|
|
});
|
|
|
|
foreach ($tasks as $task) {
|
|
try {
|
|
TaskAssignedNotification::send($task->fresh(), $actor, true);
|
|
} catch (\Throwable) {
|
|
// Assignment remains valid if notification delivery fails.
|
|
}
|
|
}
|
|
|
|
return $tasks->map(fn (Task $task) => $this->load($task->fresh()))->all();
|
|
}
|
|
|
|
public function bulkComplete(array $ids, User $actor): array
|
|
{
|
|
Gate::forUser($actor)->authorize('bulkManage', Task::class);
|
|
$tasks = DB::transaction(function () use ($ids, $actor) {
|
|
$tasks = $this->bulkTasks($ids, $actor, 'transition');
|
|
foreach ($tasks as $task) {
|
|
$before = $this->auditState($task);
|
|
$task->update(['status' => TaskStatus::Done->value, 'completed_at' => now(), 'version' => $task->version + 1]);
|
|
ActivityLogger::log('task_bulk_completed', "Task {$task->id} bulk completed", $task, $before, $this->auditState($task));
|
|
}
|
|
|
|
return $tasks;
|
|
});
|
|
|
|
return $tasks->map(fn (Task $task) => $this->load($task->fresh()))->all();
|
|
}
|
|
|
|
public function delete(Task $task): void
|
|
{
|
|
DB::transaction(function () use ($task): void {
|
|
$before = $this->auditState($task);
|
|
$task->delete();
|
|
ActivityLogger::log('task_deleted', "Task {$task->id} deleted", $task, $before, ['deleted' => true]);
|
|
});
|
|
}
|
|
|
|
private function assertAssignable(User $actor, int $assigneeId, bool $requiresPermission): User
|
|
{
|
|
$assignee = User::whereKey($assigneeId)->where('is_active', true)->first();
|
|
if (! $assignee) {
|
|
throw ValidationException::withMessages(['assigned_to' => ['کاربر انتخابشده فعال نیست.']]);
|
|
}
|
|
if ($requiresPermission && ! $actor->can('assign_tasks') && ! $actor->can('reassign_tasks')) {
|
|
abort(403, 'مجوز تخصیص کار به دیگران را ندارید.');
|
|
}
|
|
abort_unless(AccessControl::canAssignUser($actor, $assigneeId), 403, 'کاربر انتخابشده خارج از محدوده تیم شما است.');
|
|
|
|
return $assignee;
|
|
}
|
|
|
|
private function locked(Task $task, int $version): Task
|
|
{
|
|
$locked = Task::whereKey($task->id)->lockForUpdate()->firstOrFail();
|
|
if ($locked->version !== $version) {
|
|
throw new TaskVersionConflictException;
|
|
}
|
|
|
|
return $locked;
|
|
}
|
|
|
|
private function assertTransition(Task $task, TaskStatus $target): void
|
|
{
|
|
$allowed = match ($target) {
|
|
TaskStatus::InProgress => [$task->status === TaskStatus::Open],
|
|
TaskStatus::Done => [in_array($task->status, [TaskStatus::Open, TaskStatus::InProgress], true)],
|
|
TaskStatus::Open => [in_array($task->status, [TaskStatus::Done, TaskStatus::Cancelled], true)],
|
|
TaskStatus::Cancelled => [in_array($task->status, [TaskStatus::Open, TaskStatus::InProgress], true)],
|
|
};
|
|
|
|
if (! $allowed[0]) {
|
|
throw ValidationException::withMessages(['status' => ['این تغییر وضعیت برای وضعیت فعلی کار مجاز نیست.']]);
|
|
}
|
|
}
|
|
|
|
private function bulkTasks(array $ids, User $actor, string $ability, ?int $assigneeId = null)
|
|
{
|
|
$uniqueIds = collect($ids)->map(fn ($id) => (int) $id)->unique()->values();
|
|
$tasks = Task::whereKey($uniqueIds)->lockForUpdate()->get();
|
|
if ($tasks->count() !== $uniqueIds->count()) {
|
|
throw ValidationException::withMessages(['task_ids' => ['یک یا چند کار معتبر نیست.']]);
|
|
}
|
|
foreach ($tasks as $task) {
|
|
$arguments = $ability === 'assign' ? [$task, $assigneeId] : $task;
|
|
Gate::forUser($actor)->authorize($ability, $arguments);
|
|
}
|
|
|
|
return $tasks;
|
|
}
|
|
|
|
private function auditState(Task $task): array
|
|
{
|
|
return $task->only(['assigned_to', 'assigned_by', 'priority', 'status', 'due_at', 'reminder_at', 'visibility', 'version']);
|
|
}
|
|
|
|
private function load(Task $task): Task
|
|
{
|
|
return $task->load(['assignee:id,name,avatar', 'assigner:id,name', 'creator:id,name', 'taskable', 'parent:id,subject']);
|
|
}
|
|
}
|