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 $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 */ 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); } }