New_Micro_Learning/backend/app/Modules/Assignments/Http/AssignmentController.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);
}
}