338 خطوط
22 KiB
PHP
338 خطوط
22 KiB
PHP
<?php
|
|
|
|
namespace App\Modules\Assignments\Http;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\User;
|
|
use App\Modules\Assignments\Application\AssignmentAudienceImport;
|
|
use App\Modules\Assignments\Application\AssignmentResolver;
|
|
use App\Modules\Assignments\Domain\Assignment;
|
|
use App\Modules\Collaboration\Application\NotificationOrchestrator;
|
|
use App\Modules\Courses\Domain\CourseVersion;
|
|
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
|
|
use App\Modules\Identity\Application\RolePermissions;
|
|
use App\Modules\Identity\Domain\Enums\Permission;
|
|
use App\Modules\LearningPaths\Domain\LearningPathVersion;
|
|
use App\Modules\Teams\Domain\Team;
|
|
use App\Modules\Tenancy\Application\TenantContext;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
final class AssignmentController extends Controller
|
|
{
|
|
public function __construct(private readonly TenantContext $tenant, private readonly RolePermissions $permissions, private readonly AssignmentResolver $resolver, private readonly AssignmentAudienceImport $audienceImport, private readonly NotificationOrchestrator $notifications) {}
|
|
|
|
public function importAudience(Request $request): JsonResponse
|
|
{
|
|
$this->authorize($request);
|
|
$request->validate(['file' => ['required', 'file', 'mimes:xlsx,csv,txt', 'max:10240']]);
|
|
|
|
return response()->json(['data' => $this->audienceImport->resolve($this->tenant->id(), $request->file('file'))]);
|
|
}
|
|
|
|
public function contexts(Request $request): JsonResponse
|
|
{
|
|
$this->authorize($request);
|
|
$courses = CourseVersion::query()->with('course:id,title')->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->orderByDesc('published_at')->get()->map(fn (CourseVersion $version) => [
|
|
'id' => $version->getKey(), 'type' => 'course', 'title' => $version->course->title, 'version' => $version->version_number,
|
|
]);
|
|
$paths = LearningPathVersion::query()->with('path:id,title')->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->orderByDesc('published_at')->get()->map(fn (LearningPathVersion $version) => [
|
|
'id' => $version->getKey(), 'type' => 'learning_path', 'title' => $version->path->title, 'version' => $version->version_number,
|
|
]);
|
|
$users = User::query()->where('organization_id', $this->tenant->id())->where('status', 'active')->orderBy('name')->get(['id', 'name', 'email', 'department', 'job_level']);
|
|
$teams = Team::query()->where('organization_id', $this->tenant->id())->where('status', 'active')->withCount('members')->orderBy('name')->get(['id', 'name']);
|
|
$departments = User::query()->where('organization_id', $this->tenant->id())->whereNotNull('department')->distinct()->orderBy('department')->pluck('department')->values();
|
|
|
|
return response()->json(['data' => ['content' => $courses->concat($paths)->values(), 'users' => $users, 'teams' => $teams, 'departments' => $departments]]);
|
|
}
|
|
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$this->authorize($request);
|
|
$filters = $request->validate([
|
|
'workspace' => ['nullable', 'boolean'], 'courseVersionId' => ['nullable', 'string'], 'search' => ['nullable', 'string', 'max:120'],
|
|
'status' => ['nullable', Rule::in(['active', 'scheduled', 'completed', 'draft', 'stopped', 'cancelled'])],
|
|
'targetType' => ['nullable', Rule::in(['individual', 'team', 'department', 'organization', 'rule', 'bulk'])],
|
|
'sort' => ['nullable', Rule::in(['dueAt', 'progress', 'status', 'updatedAt'])], 'direction' => ['nullable', Rule::in(['asc', 'desc'])],
|
|
'page' => ['nullable', 'integer', 'min:1'], 'pageSize' => ['nullable', Rule::in([10, 25, 50, 100])],
|
|
]);
|
|
$query = Assignment::query()->where('organization_id', $this->tenant->id())
|
|
->when($filters['courseVersionId'] ?? null, fn ($builder, string $value) => $builder->where('assignable_type', 'course')->where('assignable_id', $value))
|
|
->when($filters['targetType'] ?? null, fn ($builder, string $value) => $builder->where('target_type', $value));
|
|
if (! ($filters['workspace'] ?? false)) {
|
|
$items = $query->when($filters['status'] ?? null, fn ($builder, string $value) => $builder->where('status', $value === 'stopped' ? 'cancelled' : $value))
|
|
->withCount('users')->latest()->get()->map(fn (Assignment $assignment) => $this->payload($assignment));
|
|
|
|
return response()->json(['data' => $items]);
|
|
}
|
|
$all = $this->withWorkspaceMetrics($query)->latest()->get()->map(fn (Assignment $assignment) => $this->payload($assignment));
|
|
$summary = ['total' => $all->count(), 'active' => $all->where('status', 'active')->count(), 'completed' => $all->where('status', 'completed')->count(),
|
|
'dueSoon' => $all->filter(fn (array $item) => $item['remainingDays'] !== null && $item['remainingDays'] >= 0 && $item['remainingDays'] <= 7 && in_array($item['status'], ['active', 'scheduled'], true))->count()];
|
|
$items = $all
|
|
->when($filters['status'] ?? null, fn ($collection, string $value) => $collection->where('status', $value === 'cancelled' ? 'stopped' : $value))
|
|
->when($filters['search'] ?? null, function ($collection, string $value) {
|
|
$needle = mb_strtolower($value);
|
|
|
|
return $collection->filter(fn (array $item) => str_contains(mb_strtolower($item['contentTitle'].' '.$item['targetLabel']), $needle));
|
|
});
|
|
$sort = $filters['sort'] ?? 'updatedAt';
|
|
$sortKey = ['dueAt' => 'dueAt', 'progress' => 'progress', 'status' => 'status', 'updatedAt' => 'updatedAt'][$sort];
|
|
$items = ($filters['direction'] ?? 'desc') === 'asc' ? $items->sortBy($sortKey, SORT_NATURAL) : $items->sortByDesc($sortKey, SORT_NATURAL);
|
|
$page = (int) ($filters['page'] ?? 1);
|
|
$pageSize = (int) ($filters['pageSize'] ?? 10);
|
|
$total = $items->count();
|
|
|
|
return response()->json(['data' => ['items' => $items->slice(($page - 1) * $pageSize, $pageSize)->values(), 'summary' => $summary,
|
|
'meta' => ['page' => $page, 'pageSize' => $pageSize, 'total' => $total, 'lastPage' => max(1, (int) ceil($total / $pageSize))]]]);
|
|
}
|
|
|
|
public function show(Request $request, string $assignment): JsonResponse
|
|
{
|
|
$this->authorize($request);
|
|
$model = $this->withWorkspaceMetrics(Assignment::query()->where('organization_id', $this->tenant->id()))->findOrFail($assignment);
|
|
$history = DB::table('audit_logs')->where('organization_id', $this->tenant->id())->where('entity_type', 'assignment')->where('entity_id', $model->getKey())
|
|
->where('action', 'like', '%reminder%')->latest('created_at')->limit(10)->get(['action', 'metadata', 'created_at'])->map(fn ($row) => [
|
|
'action' => $row->action, 'metadata' => json_decode((string) $row->metadata, true) ?: [], 'createdAt' => $row->created_at,
|
|
]);
|
|
|
|
return response()->json(['data' => [...$this->payload($model), 'reminderHistory' => $history]]);
|
|
}
|
|
|
|
public function remind(Request $request, string $assignment): JsonResponse
|
|
{
|
|
$this->authorize($request);
|
|
$model = Assignment::query()->where('organization_id', $this->tenant->id())->with(['users' => fn ($query) => $query->wherePivotNotIn('status', ['completed', 'cancelled'])])->findOrFail($assignment);
|
|
abort_if($model->status !== 'active', 422, 'فقط برای تخصیص فعال میتوان یادآوری فرستاد.');
|
|
$result = ['requested' => $model->users->count(), 'sent' => 0, 'skipped' => 0];
|
|
foreach ($model->users as $recipient) {
|
|
$sent = $this->notifications->send($recipient, $request->user(), ['type' => 'learning.reminder', 'title' => 'یادآوری یادگیری',
|
|
'body' => 'یک محتوای یادگیری تخصیصیافته در انتظار پیگیری شماست.', 'targetUrl' => '/learn/home', 'entityType' => 'assignment',
|
|
'entityId' => $model->getKey(), 'preferenceKey' => 'deadlineReminders', 'mandatory' => false,
|
|
'idempotencyKey' => 'designer-reminder:'.$model->getKey().':'.$recipient->getKey().':'.now()->toDateString()]);
|
|
$result[$sent ? 'sent' : 'skipped']++;
|
|
}
|
|
DB::table('audit_logs')->insert(['id' => (string) str()->ulid(), 'organization_id' => $this->tenant->id(), 'actor_id' => $request->user()->getKey(),
|
|
'action' => 'assignment.reminder_sent', 'entity_type' => 'assignment', 'entity_id' => $model->getKey(), 'metadata' => json_encode(['schemaVersion' => 1, ...$result]),
|
|
'ip_address' => $request->ip(), 'created_at' => now()]);
|
|
|
|
return response()->json(['data' => $result]);
|
|
}
|
|
|
|
public function bulk(Request $request): JsonResponse
|
|
{
|
|
$this->authorize($request);
|
|
$data = $request->validate(['ids' => ['required', 'array', 'min:1', 'max:100'], 'ids.*' => ['string', 'distinct'],
|
|
'action' => ['required', Rule::in(['cancel', 'activate', 'extend', 'remind'])], 'dueAt' => ['nullable', 'date', 'after:now']]);
|
|
$models = Assignment::query()->where('organization_id', $this->tenant->id())->whereIn('id', $data['ids'])->get();
|
|
abort_if($models->count() !== count($data['ids']), 404);
|
|
if ($data['action'] === 'extend' && empty($data['dueAt'])) {
|
|
throw ValidationException::withMessages(['dueAt' => ['مهلت جدید را مشخص کنید.']]);
|
|
}
|
|
$dueAt = isset($data['dueAt']) ? Carbon::parse($data['dueAt']) : null;
|
|
$affected = 0;
|
|
foreach ($models as $model) {
|
|
if ($data['action'] === 'cancel' && $model->status === 'active') {
|
|
$model->update(['status' => 'cancelled', 'cancelled_at' => now()]);
|
|
$affected++;
|
|
}
|
|
if ($data['action'] === 'activate' && $model->status === 'cancelled') {
|
|
$model->update(['status' => 'active', 'cancelled_at' => null]);
|
|
DB::table('assignment_users')->where('assignment_id', $model->getKey())->where('status', 'cancelled')
|
|
->update(['status' => 'assigned', 'updated_at' => now()]);
|
|
$affected++;
|
|
}
|
|
if ($data['action'] === 'extend') {
|
|
$model->update(['due_at' => $dueAt]);
|
|
DB::table('assignment_users')->where('assignment_id', $model->getKey())->whereNotIn('status', ['completed', 'cancelled'])->update(['due_at' => $dueAt, 'updated_at' => now()]);
|
|
$affected++;
|
|
}
|
|
if ($data['action'] === 'remind') {
|
|
$this->remind($request, (string) $model->getKey());
|
|
$affected++;
|
|
}
|
|
}
|
|
|
|
return response()->json(['data' => ['affected' => $affected]]);
|
|
}
|
|
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
$this->authorize($request);
|
|
$data = $request->validate([
|
|
'assignableType' => ['required', Rule::in(['course', 'learning_path'])], 'assignableId' => ['required', 'string'],
|
|
'targetType' => ['required', Rule::in(['individual', 'team', 'department', 'organization', 'rule', 'bulk'])],
|
|
'targetId' => ['nullable', 'string'], 'targetValue' => ['nullable', 'string', 'max:2000'],
|
|
'userIds' => ['nullable', 'array', 'max:1000'], 'userIds.*' => ['string'], 'mandatory' => ['required', 'boolean'],
|
|
'startsAt' => ['nullable', 'date'], 'dueAt' => ['nullable', 'date', 'after_or_equal:startsAt'],
|
|
'recurringMonths' => ['nullable', 'integer', 'between:1,60'], 'reminderDays' => ['nullable', 'integer', 'between:1,365'],
|
|
'escalationEnabled' => ['nullable', 'boolean'],
|
|
]);
|
|
$this->assertAssignable($data['assignableType'], $data['assignableId']);
|
|
$this->assertTarget($data);
|
|
$targetId = $data['targetId'] ?? null;
|
|
$targetValue = $data['targetValue'] ?? null;
|
|
if ($data['targetType'] === 'bulk') {
|
|
$ids = User::query()->where('organization_id', $this->tenant->id())->whereIn('id', $data['userIds'] ?? [])->pluck('id')->map(fn ($id) => (string) $id)->values();
|
|
if ($ids->count() !== count($data['userIds'] ?? [])) {
|
|
throw ValidationException::withMessages(['userIds' => ['یک یا چند کاربر متعلق به این سازمان نیستند.']]);
|
|
}
|
|
$targetValue = $ids->toJson();
|
|
}
|
|
$duplicate = Assignment::query()->where('organization_id', $this->tenant->id())->where('assignable_type', $data['assignableType'])->where('assignable_id', $data['assignableId'])->where('target_type', $data['targetType'])->where('target_id', $targetId)->where('target_value', $targetValue)->where('status', 'active')->exists();
|
|
if ($duplicate) {
|
|
throw ValidationException::withMessages(['target' => ['این محتوا قبلاً به همین مخاطب تخصیص داده شده است.']]);
|
|
}
|
|
$assignment = DB::transaction(function () use ($request, $data, $targetId, $targetValue): Assignment {
|
|
$assignment = Assignment::query()->create([
|
|
'organization_id' => $this->tenant->id(), 'assignable_type' => $data['assignableType'], 'assignable_id' => $data['assignableId'],
|
|
'target_type' => $data['targetType'], 'target_id' => $targetId, 'target_value' => $targetValue, 'status' => 'active',
|
|
'mandatory' => $data['mandatory'], 'starts_at' => $data['startsAt'] ?? null, 'due_at' => $data['dueAt'] ?? null,
|
|
'recurring_months' => $data['recurringMonths'] ?? null, 'reminder_days' => $data['reminderDays'] ?? null,
|
|
'escalation_policy' => ['enabled' => (bool) ($data['escalationEnabled'] ?? false)], 'source' => $data['targetType'] === 'bulk' ? 'bulk' : 'manual',
|
|
'assigned_by' => $request->user()->getKey(),
|
|
]);
|
|
if ($assignment->target_type === 'bulk') {
|
|
$users = json_decode((string) $assignment->target_value, true) ?: [];
|
|
$assignment->users()->sync(collect($users)->mapWithKeys(fn (string $id) => [$id => ['status' => 'assigned', 'assigned_at' => now(), 'starts_at' => $assignment->starts_at, 'due_at' => $assignment->due_at, 'progress' => 0, 'created_at' => now(), 'updated_at' => now()]])->all());
|
|
} else {
|
|
$this->resolver->sync($assignment);
|
|
}
|
|
|
|
return $assignment;
|
|
});
|
|
|
|
return response()->json(['data' => $this->payload($assignment->loadCount('users'))], 201);
|
|
}
|
|
|
|
public function cancel(Request $request, string $assignment): JsonResponse
|
|
{
|
|
$this->authorize($request);
|
|
$model = Assignment::query()->where('organization_id', $this->tenant->id())->findOrFail($assignment);
|
|
$model->update(['status' => 'cancelled', 'cancelled_at' => now()]);
|
|
DB::table('assignment_users')->where('assignment_id', $model->getKey())->where('status', 'assigned')->update(['status' => 'cancelled', 'updated_at' => now()]);
|
|
|
|
return response()->json(['data' => $this->payload($model->loadCount('users'))]);
|
|
}
|
|
|
|
public function update(Request $request, string $assignment): JsonResponse
|
|
{
|
|
$this->authorize($request);
|
|
$model = Assignment::query()->where('organization_id', $this->tenant->id())->findOrFail($assignment);
|
|
abort_if($model->status !== 'active', 422, 'فقط تخصیص فعال قابل ویرایش است.');
|
|
$data = $request->validate([
|
|
'mandatory' => ['required', 'boolean'],
|
|
'startsAt' => ['nullable', 'date'],
|
|
'dueAt' => ['nullable', 'date', 'after_or_equal:startsAt'],
|
|
'recurringMonths' => ['nullable', 'integer', 'between:1,60'],
|
|
'reminderDays' => ['nullable', 'integer', 'between:1,365'],
|
|
'escalationEnabled' => ['nullable', 'boolean'],
|
|
]);
|
|
|
|
DB::transaction(function () use ($model, $data): void {
|
|
$model->update([
|
|
'mandatory' => $data['mandatory'],
|
|
'starts_at' => $data['startsAt'] ?? null,
|
|
'due_at' => $data['dueAt'] ?? null,
|
|
'recurring_months' => $data['recurringMonths'] ?? null,
|
|
'reminder_days' => $data['reminderDays'] ?? null,
|
|
'escalation_policy' => ['enabled' => (bool) ($data['escalationEnabled'] ?? false)],
|
|
]);
|
|
DB::table('assignment_users')->where('assignment_id', $model->getKey())
|
|
->whereNotIn('status', ['completed', 'cancelled'])
|
|
->update(['starts_at' => $model->starts_at, 'due_at' => $model->due_at, 'updated_at' => now()]);
|
|
});
|
|
|
|
return response()->json(['data' => $this->payload($model->fresh()->loadCount('users'))]);
|
|
}
|
|
|
|
public function sync(Request $request): JsonResponse
|
|
{
|
|
$this->authorize($request);
|
|
$this->resolver->syncOrganization($this->tenant->id());
|
|
|
|
return response()->json(['data' => ['synced' => true]]);
|
|
}
|
|
|
|
private function assertAssignable(string $type, string $id): void
|
|
{
|
|
$model = $type === 'course'
|
|
? CourseVersion::query()->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->find($id)
|
|
: LearningPathVersion::query()->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->find($id);
|
|
if (! $model) {
|
|
throw ValidationException::withMessages(['assignableId' => ['فقط یک نسخه منتشرشده و فعال قابل تخصیص است.']]);
|
|
}
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
private function assertTarget(array $data): void
|
|
{
|
|
$type = $data['targetType'];
|
|
if (in_array($type, ['individual', 'team'], true) && empty($data['targetId'])) {
|
|
throw ValidationException::withMessages(['targetId' => ['مخاطب را انتخاب کنید.']]);
|
|
}
|
|
if (in_array($type, ['department', 'rule'], true) && empty($data['targetValue'])) {
|
|
throw ValidationException::withMessages(['targetValue' => ['قاعده یا دپارتمان را مشخص کنید.']]);
|
|
}
|
|
if ($type === 'individual' && ! User::query()->where('organization_id', $this->tenant->id())->whereKey($data['targetId'])->exists()) {
|
|
throw ValidationException::withMessages(['targetId' => ['کاربر معتبر نیست.']]);
|
|
}
|
|
if ($type === 'team' && ! Team::query()->where('organization_id', $this->tenant->id())->whereKey($data['targetId'])->exists()) {
|
|
throw ValidationException::withMessages(['targetId' => ['تیم معتبر نیست.']]);
|
|
}
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function payload(Assignment $assignment): array
|
|
{
|
|
$content = $assignment->assignable_type === 'course'
|
|
? CourseVersion::query()->with('course:id,title')->find($assignment->assignable_id)?->course?->title
|
|
: LearningPathVersion::query()->with('path:id,title')->find($assignment->assignable_id)?->path?->title;
|
|
$target = match ($assignment->target_type) {
|
|
'individual' => User::query()->find($assignment->target_id)?->name,
|
|
'team' => Team::query()->find($assignment->target_id)?->name,
|
|
'department' => $assignment->target_value,
|
|
'organization' => 'کل سازمان',
|
|
'rule' => 'قاعده پویا',
|
|
'bulk' => 'فهرست انتخابی',
|
|
default => '—',
|
|
};
|
|
|
|
$recipientCount = (int) ($assignment->users_count ?? $assignment->users()->count());
|
|
$completedCount = (int) ($assignment->completed_count ?? 0);
|
|
$progress = isset($assignment->average_progress) ? (int) round((float) $assignment->average_progress) : null;
|
|
$status = $assignment->status === 'cancelled' ? 'stopped'
|
|
: ($assignment->status === 'draft' ? 'draft'
|
|
: ($assignment->starts_at?->isFuture() ? 'scheduled' : ($recipientCount > 0 && $completedCount >= $recipientCount ? 'completed' : 'active')));
|
|
$remainingDays = $assignment->due_at ? now()->startOfDay()->diffInDays($assignment->due_at->startOfDay(), false) : null;
|
|
|
|
return [
|
|
'id' => $assignment->getKey(), 'assignableType' => $assignment->assignable_type, 'assignableId' => $assignment->assignable_id,
|
|
'contentTitle' => $content ?? 'محتوای حذفشده', 'targetType' => $assignment->target_type, 'targetId' => $assignment->target_id,
|
|
'targetValue' => $assignment->target_value, 'targetLabel' => $target ?? '—', 'status' => $status, 'rawStatus' => $assignment->status,
|
|
'mandatory' => $assignment->mandatory, 'startsAt' => $assignment->starts_at?->toISOString(), 'dueAt' => $assignment->due_at?->toISOString(),
|
|
'recurringMonths' => $assignment->recurring_months, 'reminderDays' => $assignment->reminder_days,
|
|
'escalationEnabled' => (bool) ($assignment->escalation_policy['enabled'] ?? false), 'recipientCount' => $recipientCount,
|
|
'completedCount' => $completedCount, 'progress' => $progress, 'remainingDays' => $remainingDays,
|
|
'lastActivityAt' => $assignment->last_activity_at ?? null, 'averageScore' => null,
|
|
'createdAt' => $assignment->created_at?->toISOString(), 'updatedAt' => $assignment->updated_at?->toISOString(),
|
|
];
|
|
}
|
|
|
|
private function withWorkspaceMetrics($query)
|
|
{
|
|
return $query->withCount('users')
|
|
->withCount(['users as completed_count' => fn ($builder) => $builder->where('assignment_users.status', 'completed')])
|
|
->withAvg('users as average_progress', 'assignment_users.progress')
|
|
->withMax('users as last_activity_at', 'assignment_users.updated_at');
|
|
}
|
|
|
|
private function authorize(Request $request): void
|
|
{
|
|
abort_unless($this->permissions->allows($request->user(), Permission::AssignmentsManage), 403);
|
|
}
|
|
}
|