user(); $this->authorize($manager, Permission::ManagerAssignmentsManage); $teamIds = $this->scope->teamIds($manager); $memberIds = $this->scope->memberIds($manager); $courses = CourseVersion::query()->with('course:id,title')->where('organization_id', $manager->organization_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', $manager->organization_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]); $members = User::query()->where('organization_id', $manager->organization_id)->whereIn('id', $memberIds)->where('status', AccountStatus::Active) ->orderBy('name')->get(['id', 'name', 'email', 'department']); $teams = Team::query()->where('organization_id', $manager->organization_id)->whereIn('id', $teamIds)->where('status', 'active') ->withCount(['members' => fn ($query) => $query->where('users.status', AccountStatus::Active)])->orderBy('name')->get(['id', 'name']); return response()->json(['data' => ['content' => $courses->concat($paths)->values(), 'members' => $members, 'teams' => $teams->map(fn (Team $team) => ['id' => $team->getKey(), 'name' => $team->name, 'memberCount' => $team->members_count])->values()]]); } public function index(Request $request): JsonResponse { $manager = $request->user(); $this->authorize($manager, Permission::ManagerAssignmentsManage); $memberIds = $this->scope->memberIds($manager); $assignments = Assignment::query()->where('organization_id', $manager->organization_id)->where('assigned_by', $manager->getKey()) ->with(['users:id,name'])->latest()->get()->filter(function (Assignment $assignment) use ($memberIds) { $recipients = $assignment->users->pluck('id')->map(fn ($id) => (string) $id); return $recipients->isNotEmpty() && $recipients->diff($memberIds)->isEmpty(); })->values(); $courseTitles = CourseVersion::query()->whereIn('id', $assignments->where('assignable_type', 'course')->pluck('assignable_id'))->pluck('title', 'id'); $pathTitles = LearningPathVersion::query()->whereIn('id', $assignments->where('assignable_type', 'learning_path')->pluck('assignable_id'))->pluck('title', 'id'); return response()->json(['data' => $assignments->map(fn (Assignment $assignment) => $this->payload($assignment, $assignment->assignable_type === 'course' ? $courseTitles[$assignment->assignable_id] ?? null : $pathTitles[$assignment->assignable_id] ?? null))->values()]); } public function store(Request $request): JsonResponse { $manager = $request->user(); $this->authorize($manager, Permission::ManagerAssignmentsManage); $data = $request->validate([ 'assignableType' => ['required', Rule::in(['course', 'learning_path'])], 'assignableId' => ['required', 'string'], 'audienceType' => ['required', Rule::in(['team', 'employees'])], 'teamId' => ['nullable', 'string'], 'userIds' => ['nullable', 'array', 'min:1', 'max:1000'], 'userIds.*' => ['string', 'distinct'], 'mandatory' => ['required', 'boolean'], 'dueAt' => ['nullable', 'date', 'after:now'], 'notifyNow' => ['required', 'boolean'], 'reminderDays' => ['nullable', 'integer', 'between:1,365'], 'reminderOnDeadline' => ['required', 'boolean'], 'onlyIfNotStarted' => ['required', 'boolean'], ]); $title = $this->assertAssignable($manager, $data['assignableType'], $data['assignableId']); $recipients = $this->resolveAudience($manager, $data); $existingIds = DB::table('assignment_users as au')->join('assignments as a', 'a.id', '=', 'au.assignment_id') ->where('a.organization_id', $manager->organization_id)->where('a.status', 'active') ->where('a.assignable_type', $data['assignableType'])->where('a.assignable_id', $data['assignableId']) ->whereIn('au.user_id', $recipients->pluck('id'))->whereIn('au.status', ['assigned', 'in_progress'])->distinct()->pluck('au.user_id')->map(fn ($id) => (string) $id); $eligible = $recipients->reject(fn (User $user) => $existingIds->contains((string) $user->getKey()))->values(); if ($eligible->isEmpty()) { throw ValidationException::withMessages(['audience' => ['این آموزش قبلاً برای همه مخاطبان انتخابی فعال است.']]); } $notificationConfig = ['notifyNow' => (bool) $data['notifyNow'], 'reminderDays' => $data['reminderDays'] ?? null, 'reminderOnDeadline' => (bool) $data['reminderOnDeadline'], 'onlyIfNotStarted' => (bool) $data['onlyIfNotStarted']]; $assignment = DB::transaction(function () use ($manager, $data, $eligible, $notificationConfig): Assignment { $assignment = Assignment::query()->create([ 'organization_id' => $manager->organization_id, 'assignable_type' => $data['assignableType'], 'assignable_id' => $data['assignableId'], 'target_type' => 'bulk', 'target_value' => $eligible->pluck('id')->values()->toJson(), 'status' => 'active', 'mandatory' => $data['mandatory'], 'due_at' => $data['dueAt'] ?? null, 'reminder_days' => ! empty($data['dueAt']) ? ($data['reminderDays'] ?? null) : null, 'escalation_policy' => ['enabled' => false, 'notifications' => $notificationConfig], 'source' => 'manager', 'assigned_by' => $manager->getKey(), ]); $assignment->users()->sync($eligible->mapWithKeys(fn (User $user) => [$user->getKey() => ['status' => 'assigned', 'assigned_at' => now(), 'due_at' => $assignment->due_at, 'progress' => 0, 'created_at' => now(), 'updated_at' => now()]])->all()); return $assignment; })->load(['users:id,name']); $immediate = ['sent' => 0, 'skipped' => 0]; if ($data['notifyNow']) { foreach ($eligible as $recipient) { $sent = $this->notifications->send($recipient, $manager, $this->message($assignment, $title, $recipient, 'learning.assigned', 'آموزش جدید برای شما تخصیص یافت', 'assigned:'.$assignment->getKey().':'.$recipient->getKey(), (bool) $assignment->mandatory, 'assignmentNotifications')); $immediate[$sent ? 'sent' : 'skipped']++; } } $scheduled = $this->scheduleReminders($assignment, $eligible, $manager, $title); $this->audit($request, 'manager.assignment.created', $assignment, ['recipients' => $eligible->count(), 'duplicatesSkipped' => $existingIds->count()]); return response()->json(['data' => ['assignment' => $this->payload($assignment, $title), 'result' => ['assigned' => $eligible->count(), 'duplicatesSkipped' => $existingIds->count(), 'notificationsSent' => $immediate['sent'], 'notificationsSkipped' => $immediate['skipped'], 'notificationsScheduled' => $scheduled]]], 201); } public function deadline(Request $request, string $assignment): JsonResponse { $manager = $request->user(); $this->authorize($manager, Permission::ManagerDeadlinesManage); $model = $this->managedAssignment($manager, $assignment); abort_if($model->status !== 'active', 422, 'فقط تخصیص فعال قابل ویرایش است.'); $data = $request->validate(['dueAt' => ['nullable', 'date', 'after:now']]); DB::transaction(function () use ($model, $data): void { $model->update(['due_at' => $data['dueAt'] ?? null]); DB::table('assignment_users')->where('assignment_id', $model->getKey())->whereNotIn('status', ['completed', 'cancelled']) ->update(['due_at' => $model->due_at, 'updated_at' => now()]); }); $this->notifications->cancelPending($manager->organization_id, 'assignment', $model->getKey()); $fresh = $model->fresh()->load(['users:id,name']); $scheduled = $fresh->due_at ? $this->scheduleReminders($fresh, $fresh->users, $manager, $this->contentTitle($fresh)) : 0; $this->audit($request, $fresh->due_at ? 'manager.assignment.deadline_updated' : 'manager.assignment.deadline_removed', $fresh, ['dueAt' => $fresh->due_at?->toISOString()]); return response()->json(['data' => ['assignment' => $this->payload($fresh), 'notificationsScheduled' => $scheduled]]); } public function remind(Request $request, string $assignment): JsonResponse { $manager = $request->user(); $this->authorize($manager, Permission::ManagerRemindersSend); $model = $this->managedAssignment($manager, $assignment); abort_if($model->status !== 'active', 422, 'فقط برای تخصیص فعال می‌توان یادآوری فرستاد.'); $data = $request->validate(['userIds' => ['nullable', 'array', 'min:1', 'max:1000'], 'userIds.*' => ['string', 'distinct']]); $allowedIds = $model->users->filter(fn (User $user) => ! in_array($user->pivot->status, ['completed', 'cancelled'], true))->pluck('id')->map(fn ($id) => (string) $id); $requested = collect($data['userIds'] ?? $allowedIds)->map(fn ($id) => (string) $id)->unique()->values(); if ($requested->diff($allowedIds)->isNotEmpty()) { throw ValidationException::withMessages(['userIds' => ['یادآوری فقط برای مخاطبان فعال و مجاز این تخصیص قابل ارسال است.']]); } $recipients = User::query()->where('organization_id', $manager->organization_id)->whereIn('id', $requested)->get(); $title = $this->contentTitle($model); $result = ['requested' => $requested->count(), 'sent' => 0, 'skipped' => 0]; foreach ($recipients as $recipient) { $sent = $this->notifications->send($recipient, $manager, $this->message($model, $title, $recipient, 'learning.reminder', 'یادآوری یادگیری', 'manual-reminder:'.$model->getKey().':'.$recipient->getKey().':'.now()->toDateString(), false, 'deadlineReminders')); $result[$sent ? 'sent' : 'skipped']++; } $this->audit($request, 'manager.assignment.reminder_sent', $model, $result); return response()->json(['data' => $result]); } /** @param array $data @return Collection */ private function resolveAudience(User $manager, array $data): Collection { $memberIds = $this->scope->memberIds($manager); if ($data['audienceType'] === 'team') { if (empty($data['teamId']) || ! $this->scope->teamIds($manager)->contains((string) $data['teamId'])) { throw ValidationException::withMessages(['teamId' => ['این تیم در محدوده مدیریت شما نیست.']]); } $requested = DB::table('team_memberships')->where('team_id', $data['teamId'])->pluck('user_id')->map(fn ($id) => (string) $id); } else { $requested = collect($data['userIds'] ?? [])->map(fn ($id) => (string) $id)->unique()->values(); if ($requested->isEmpty() || $requested->diff($memberIds)->isNotEmpty()) { throw ValidationException::withMessages(['userIds' => ['یک یا چند کاربر خارج از محدوده تیم شما هستند.']]); } } $recipients = User::query()->where('organization_id', $manager->organization_id)->where('status', AccountStatus::Active)->whereIn('id', $requested)->get(); if ($recipients->isEmpty()) { throw ValidationException::withMessages(['audience' => ['مخاطب فعال و مجازی برای تخصیص پیدا نشد.']]); } return $recipients; } private function managedAssignment(User $manager, string $id): Assignment { $model = Assignment::query()->where('organization_id', $manager->organization_id)->where('assigned_by', $manager->getKey())->with(['users:id,name'])->findOrFail($id); $recipientIds = $model->users->pluck('id')->map(fn ($value) => (string) $value); abort_if($recipientIds->isEmpty() || $recipientIds->diff($this->scope->memberIds($manager))->isNotEmpty(), 404); return $model; } private function assertAssignable(User $manager, string $type, string $id): string { $model = $type === 'course' ? CourseVersion::query()->where('organization_id', $manager->organization_id)->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->find($id) : LearningPathVersion::query()->where('organization_id', $manager->organization_id)->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->find($id); if (! $model) { throw ValidationException::withMessages(['assignableId' => ['فقط محتوای منتشرشده و فعال قابل تخصیص است.']]); } return $this->contentTitleFromModel($type, $model); } private function contentTitle(Assignment $assignment): string { $model = $assignment->assignable_type === 'course' ? CourseVersion::query()->find($assignment->assignable_id) : LearningPathVersion::query()->find($assignment->assignable_id); return $model ? $this->contentTitleFromModel($assignment->assignable_type, $model) : 'محتوای یادگیری'; } private function contentTitleFromModel(string $type, object $model): string { return $type === 'course' ? ($model->course()->value('title') ?? $model->title) : ($model->path()->value('title') ?? $model->title); } private function scheduleReminders(Assignment $assignment, Collection $recipients, User $manager, string $title): int { if (! $assignment->due_at) { return 0; } $config = $assignment->escalation_policy['notifications'] ?? []; $moments = collect(); if (! empty($config['reminderDays'])) { $moments->push(['type' => 'learning.deadline_soon', 'at' => CarbonImmutable::parse($assignment->due_at)->subDays((int) $config['reminderDays'])]); } if ($config['reminderOnDeadline'] ?? false) { $moments->push(['type' => 'learning.due_today', 'at' => CarbonImmutable::parse($assignment->due_at)]); } $scheduled = 0; foreach ($moments->filter(fn (array $moment) => $moment['at']->isFuture()) as $moment) { foreach ($recipients as $recipient) { $key = 'scheduled:'.$moment['type'].':'.$assignment->getKey().':'.$recipient->getKey().':'.$moment['at']->timestamp; $scheduled += (int) $this->notifications->schedule($recipient, $manager, [...$this->message($assignment, $title, $recipient, $moment['type'], 'یادآوری مهلت یادگیری', $key, false, 'deadlineReminders'), 'scheduledAt' => $moment['at'], 'condition' => ($config['onlyIfNotStarted'] ?? false) ? ['status' => 'not_started'] : []]); } } return $scheduled; } /** @return array */ private function message(Assignment $assignment, string $title, User $recipient, string $type, string $heading, string $key, bool $mandatory, string $preference): array { return ['type' => $type, 'title' => $heading, 'body' => '«'.$title.'» برای پیگیری در بخش یادگیری شما قرار دارد.', 'targetUrl' => '/learn/home', 'entityType' => 'assignment', 'entityId' => $assignment->getKey(), 'preferenceKey' => $preference, 'mandatory' => $mandatory, 'idempotencyKey' => $key.':'.$recipient->getKey()]; } /** @return array */ private function payload(Assignment $assignment, ?string $title = null): array { $assignment->loadMissing(['users:id,name']); $config = $assignment->escalation_policy['notifications'] ?? []; return ['id' => $assignment->getKey(), 'assignableType' => $assignment->assignable_type, 'assignableId' => $assignment->assignable_id, 'contentTitle' => $title ?? $this->contentTitle($assignment), 'status' => $assignment->status, 'mandatory' => $assignment->mandatory, 'dueAt' => $assignment->due_at?->toISOString(), 'recipientCount' => $assignment->users->count(), 'recipientIds' => $assignment->users->pluck('id')->values(), 'recipientNames' => $assignment->users->pluck('name')->take(3)->values(), 'reminderDays' => $config['reminderDays'] ?? null, 'reminderOnDeadline' => (bool) ($config['reminderOnDeadline'] ?? false), 'onlyIfNotStarted' => (bool) ($config['onlyIfNotStarted'] ?? false), 'createdAt' => $assignment->created_at?->toISOString()]; } /** @param array $metadata */ private function audit(Request $request, string $action, Assignment $assignment, array $metadata): void { DB::table('audit_logs')->insert(['id' => (string) str()->ulid(), 'organization_id' => $assignment->organization_id, 'actor_id' => $request->user()->getKey(), 'action' => $action, 'entity_type' => 'assignment', 'entity_id' => $assignment->getKey(), 'metadata' => json_encode(['schemaVersion' => 1, ...$metadata]), 'ip_address' => $request->ip(), 'created_at' => now()]); } private function authorize(User $manager, Permission $permission): void { abort_unless($this->permissions->allows($manager, $permission), 403); } }