New_Micro_Learning/backend/app/Modules/Manager/Http/ManagerAssignmentController...

274 خطوط
19 KiB
PHP

<?php
namespace App\Modules\Manager\Http;
use App\Http\Controllers\Controller;
use App\Models\User;
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\AccountStatus;
use App\Modules\Identity\Domain\Enums\Permission;
use App\Modules\LearningPaths\Domain\LearningPathVersion;
use App\Modules\Manager\Application\ManagerScope;
use App\Modules\Teams\Domain\Team;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
final class ManagerAssignmentController extends Controller
{
public function __construct(
private readonly RolePermissions $permissions,
private readonly ManagerScope $scope,
private readonly NotificationOrchestrator $notifications,
) {}
public function contexts(Request $request): JsonResponse
{
$manager = $request->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<string, mixed> $data @return Collection<int, User> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed> $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);
}
}