storageDirectory(); if (! is_dir($dir)) { mkdir($dir, 0755, true); } } /** * @return list */ public function candidateAbsolutePaths(string $storedName): array { $safe = basename(trim($storedName)); return [ $this->storageDirectory().DIRECTORY_SEPARATOR.$safe, public_path('uploads/print_requests/'.$safe), ]; } public function resolveExistingFilePath(string $storedName): ?string { if (trim($storedName) === '') { return null; } foreach ($this->candidateAbsolutePaths($storedName) as $path) { if (is_file($path)) { return $path; } } return null; } /** * @return array{sundays: list, times: list} */ public function buildRequiredByOptions(): array { $tz = new \DateTimeZone('America/Chicago'); $currentDate = new \DateTime('now', $tz); $currentYear = (int) $currentDate->format('Y'); $endYear = ($currentDate->format('n') > 6) ? $currentYear + 1 : $currentYear; $endDate = new \DateTime("first Sunday of June {$endYear}", $tz); $endDate->modify('+1 week'); $sundays = []; $date = new \DateTime('now', $tz); $date->setTime(0, 0, 0); if ($date->format('w') !== '0') { $date->modify('next Sunday'); } while ($date < $endDate) { $sundays[] = $date->format('Y-m-d'); $date->modify('+1 week'); } $times = []; $start = new \DateTime('10:00', $tz); $end = new \DateTime('13:00', $tz); $interval = new \DateInterval('PT15M'); $period = new \DatePeriod($start, $interval, $end); foreach ($period as $time) { $times[] = $time->format('H:i'); } return [ 'sundays' => $sundays, 'times' => $times, ]; } /** * @return list> */ public function teacherPrintRequests(int $teacherId): array { return DB::table('print_requests as pr') ->select('pr.*', 'admins.firstname as admin_firstname', 'admins.lastname as admin_lastname') ->leftJoin('users as admins', 'admins.id', '=', 'pr.admin_id') ->where('pr.teacher_id', $teacherId) ->orderBy('pr.id', 'desc') ->get() ->map(fn ($r) => $this->normalizePrintRequestRow((array) $r)) ->all(); } /** * Admin queue ordering per legacy raw SQL. * * @return list> */ public function adminPrintRequests(): array { return DB::table('print_requests as pr') ->select( 'pr.*', 'u.firstname', 'u.lastname', 'cs.class_section_name', 'admins.firstname as admin_firstname', 'admins.lastname as admin_lastname' ) ->leftJoin('users as u', 'u.id', '=', 'pr.teacher_id') ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'pr.class_id') ->leftJoin('users as admins', 'admins.id', '=', 'pr.admin_id') ->orderByRaw("CASE WHEN pr.status = 'not_assigned' THEN 1 WHEN pr.status = 'assigned' THEN 2 WHEN pr.status = 'done' THEN 3 WHEN pr.status = 'delivered' THEN 4 ELSE 5 END ASC") ->orderBy('pr.required_by', 'asc') ->get() ->map(fn ($r) => $this->normalizePrintRequestRow((array) $r)) ->all(); } public function defaultClassSectionIdForTeacher(int $teacherId): ?int { $row = DB::table('teacher_class as tc') ->join('classSection as cs', 'tc.class_section_id', '=', 'cs.class_section_id') ->where('tc.teacher_id', $teacherId) ->whereNotNull('tc.class_section_id') ->select('cs.class_section_id') ->orderBy('tc.class_section_id') ->first(); return $row ? (int) $row->class_section_id : null; } /** * @param array $requestData merged row after update for notifications */ public function notifyAdminsForPrintRequest(int $printRequestId, array $requestData, string $event = 'created'): void { try { $event = strtolower(trim($event)); if ( ! Schema::hasTable('admin_notification_subjects') || ! Schema::hasTable('notifications') || ! Schema::hasTable('user_notifications') ) { return; } $adminIds = AdminNotificationSubject::query() ->where('subject', 'print_requests') ->pluck('admin_id') ->map(fn ($id) => (int) $id) ->unique() ->filter(fn ($id) => $id > 0) ->values() ->all(); if ($adminIds === []) { return; } $teacherId = (int) ($requestData['teacher_id'] ?? 0); $teacherName = ''; if ($teacherId > 0) { $teacher = User::query()->find($teacherId); if ($teacher) { $teacherName = trim(($teacher->firstname ?? '').' '.($teacher->lastname ?? '')); } } if ($teacherName === '') { $teacherName = $teacherId > 0 ? ('Teacher #'.$teacherId) : 'Teacher'; } $className = ''; $classId = $requestData['class_id'] ?? null; if ($classId !== null && $classId !== '') { $className = (string) (ClassSection::getClassSectionNameBySectionId((int) $classId) ?? ''); } $eventMap = [ 'created' => [ 'title' => 'New Print Request', 'intro' => 'A new print request has been submitted.', 'lead' => 'New print request from ', ], 'updated' => [ 'title' => 'Print Request Updated', 'intro' => 'A print request has been updated.', 'lead' => 'Updated print request from ', ], 'deleted' => [ 'title' => 'Print Request Removed', 'intro' => 'A print request has been deleted.', 'lead' => 'Print request removed for ', ], ]; $eventConfig = $eventMap[$event] ?? $eventMap['created']; $filePath = trim((string) ($requestData['file_path'] ?? '')); $isCopyRequest = $filePath === ''; $messageParts = [$eventConfig['lead'].$teacherName]; if ($className !== '') { $messageParts[] = 'Class: '.$className; } if (! empty($requestData['num_copies'])) { $messageParts[] = 'Copies: '.(int) $requestData['num_copies']; } if (! empty($requestData['required_by'])) { $messageParts[] = 'Needed by: '.$requestData['required_by']; } if (! empty($requestData['page_selection'])) { $messageParts[] = 'Pages: '.$requestData['page_selection']; } if (! empty($requestData['pickup_method'])) { $messageParts[] = 'Pickup: '.$requestData['pickup_method']; } $message = implode(' | ', $messageParts); $actionUrl = $this->urls->spaAdminPrintRequestsUrl(); $payload = [ 'title' => $eventConfig['title'], 'message' => $message, 'target_group' => 'admin', 'delivery_channels' => 'in_app,email', 'priority' => 'normal', 'status' => 'pending', 'action_url' => $isCopyRequest ? '' : $actionUrl, 'scheduled_at' => function_exists('utc_now') ? utc_now() : now(), 'school_year' => Configuration::getConfigValueByKey('school_year'), 'semester' => Configuration::getConfigValueByKey('semester'), ]; $notificationFields = Schema::getColumnListing('notifications'); if ($notificationFields !== []) { $payload = array_intersect_key($payload, array_flip($notificationFields)); } $notificationId = (int) DB::table('notifications')->insertGetId(array_merge($payload, [ 'created_at' => now(), 'updated_at' => now(), ])); if ($notificationId <= 0) { return; } $userNotificationFields = Schema::getColumnListing('user_notifications'); $allowedUn = array_flip($userNotificationFields !== [] ? $userNotificationFields : []); foreach ($adminIds as $adminId) { $row = [ 'notification_id' => $notificationId, 'user_id' => $adminId, 'is_read' => 0, 'delivered' => 0, ]; if ($allowedUn !== []) { $row = array_intersect_key($row, $allowedUn); } DB::table('user_notifications')->insert($row); } $adminRows = User::query() ->select(['id', 'firstname', 'lastname', 'email']) ->whereIn('id', $adminIds) ->get(); $subject = $eventConfig['title']; foreach ($adminRows as $adminRow) { $email = trim((string) ($adminRow->email ?? '')); if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { continue; } $body = '

'.e($eventConfig['intro']).'

' .'

From: '.e($teacherName).'

' .($className !== '' ? '

Class: '.e($className).'

' : '') .(! empty($requestData['num_copies']) ? '

Copies: '.(int) $requestData['num_copies'].'

' : '') .(! empty($requestData['required_by']) ? '

Needed by: '.e((string) $requestData['required_by']).'

' : '') .(! empty($requestData['page_selection']) ? '

Pages: '.e((string) $requestData['page_selection']).'

' : '') .(! empty($requestData['pickup_method']) ? '

Pickup Method: '.e((string) $requestData['pickup_method']).'

' : '') .($isCopyRequest ? '' : '

View print requests

'); $this->mail->send($email, $subject, $body, 'notifications'); } } catch (\Throwable $e) { logger()->error('Print request notification failed: '.$e->getMessage()); } } /** * @param array $row * @return array */ public function normalizePrintRequestRow(array $row): array { foreach (['required_by'] as $key) { if (! isset($row[$key])) { continue; } $v = $row[$key]; if ($v instanceof CarbonInterface) { $row[$key] = $v->format('Y-m-d H:i:s'); } } return $row; } /** * @return array> */ public static function allowedStatusTransitions(): array { return [ 'not_assigned' => ['assigned'], 'assigned' => ['not_assigned', 'done'], 'done' => ['delivered'], 'delivered' => [], ]; } public function teacherOwnsRequest(PrintRequest $request, int $teacherUserId): bool { return (int) $request->teacher_id === $teacherUserId; } public function teacherMayEditOrDelete(PrintRequest $request): bool { return in_array((string) $request->status, ['not_assigned', 'assigned'], true); } /** * Teacher dashboard payload (parity with legacy teacher_index). * * @return array{print_requests: list>, sundays: list, times: list, class_id: int|null} */ public function teacherBootstrap(int $teacherId): array { $opts = $this->buildRequiredByOptions(); return [ 'print_requests' => $this->teacherPrintRequests($teacherId), 'sundays' => $opts['sundays'], 'times' => $opts['times'], 'class_id' => $this->defaultClassSectionIdForTeacher($teacherId), ]; } /** * @param array $data validated: class_id, page_selection?, num_copies, required_by, pickup_method */ public function storeTeacherUpload(array $data, UploadedFile $file, int $teacherId): PrintRequest { $this->ensureStorageDirectoryExists(); $ext = $file->getClientOriginalExtension(); $stored = Str::uuid()->toString().($ext !== '' ? '.'.$ext : ''); $file->move($this->storageDirectory(), $stored); $row = [ 'teacher_id' => $teacherId, 'class_id' => (int) $data['class_id'], 'file_path' => $stored, 'page_selection' => isset($data['page_selection']) ? trim((string) $data['page_selection']) : null, 'num_copies' => (int) $data['num_copies'], 'required_by' => $data['required_by'], 'pickup_method' => (string) $data['pickup_method'], 'status' => 'not_assigned', ]; $model = PrintRequest::query()->create($row); $payload = array_merge($row, ['id' => $model->id, 'teacher_id' => $teacherId]); $this->notifyAdminsForPrintRequest((int) $model->id, $payload, 'created'); return $model->fresh(); } /** * @param array $data num_copies, required_by, pickup_method, page_selection? */ public function updateTeacherFields(PrintRequest $request, array $data, ?UploadedFile $newFile): PrintRequest { $update = [ 'num_copies' => (int) $data['num_copies'], 'required_by' => $data['required_by'], 'pickup_method' => (string) $data['pickup_method'], 'page_selection' => isset($data['page_selection']) ? trim((string) $data['page_selection']) : null, ]; if ($newFile !== null) { $this->ensureStorageDirectoryExists(); $old = trim((string) ($request->file_path ?? '')); if ($old !== '') { foreach ($this->candidateAbsolutePaths($old) as $abs) { if (is_file($abs)) { @unlink($abs); break; } } } $ext = $newFile->getClientOriginalExtension(); $stored = Str::uuid()->toString().($ext !== '' ? '.'.$ext : ''); $newFile->move($this->storageDirectory(), $stored); $update['file_path'] = $stored; } $request->update($update); $merged = array_merge($request->toArray(), $update, ['id' => $request->id]); $this->notifyAdminsForPrintRequest((int) $request->id, $merged, 'updated'); return $request->fresh(); } public function applyAdminStatus(PrintRequest $request, string $newStatus, int $adminUserId): PrintRequest { $current = (string) $request->status; $allowed = self::allowedStatusTransitions(); if (! isset($allowed[$current]) || ! in_array($newStatus, $allowed[$current], true)) { if ($current === $newStatus) { return $request; } throw new \InvalidArgumentException('Invalid status transition.'); } $data = ['status' => $newStatus]; if ($newStatus === 'assigned') { $data['admin_id'] = $adminUserId; } elseif ($newStatus === 'not_assigned') { $data['admin_id'] = null; } $request->update($data); return $request->fresh(); } public function deleteTeacherRequest(PrintRequest $request): void { $this->notifyAdminsForPrintRequest((int) $request->id, $request->toArray(), 'deleted'); $fp = trim((string) ($request->file_path ?? '')); if ($fp !== '') { foreach ($this->candidateAbsolutePaths($fp) as $abs) { if (is_file($abs)) { @unlink($abs); break; } } } $request->delete(); } public function copyFromExisting(PrintRequest $source, int $teacherId): PrintRequest { $fp = trim((string) ($source->file_path ?? '')); if ($fp === '' || $this->resolveExistingFilePath($fp) === null) { throw new \InvalidArgumentException('The original file is unavailable for copying.'); } $row = [ 'teacher_id' => $teacherId, 'class_id' => (int) $source->class_id, 'file_path' => $fp, 'page_selection' => $source->page_selection, 'num_copies' => (int) $source->num_copies, 'required_by' => $source->required_by, 'pickup_method' => (string) $source->pickup_method, 'status' => 'not_assigned', ]; $model = PrintRequest::query()->create($row); $this->notifyAdminsForPrintRequest((int) $model->id, array_merge($row, ['id' => $model->id, 'teacher_id' => $teacherId]), 'created'); return $model->fresh(); } /** * Copy-center request without uploading a file (legacy createCopy). * * @param array $data class_id, num_copies, required_by, pickup_method */ public function storeHandCopy(array $data, int $teacherId): PrintRequest { $row = [ 'teacher_id' => $teacherId, 'class_id' => (int) $data['class_id'], 'file_path' => '', 'page_selection' => null, 'num_copies' => (int) $data['num_copies'], 'required_by' => $data['required_by'], 'pickup_method' => (string) $data['pickup_method'], 'status' => 'not_assigned', ]; $model = PrintRequest::query()->create($row); $this->notifyAdminsForPrintRequest((int) $model->id, array_merge($row, ['id' => $model->id, 'teacher_id' => $teacherId]), 'created'); return $model->fresh(); } /** * Teacher may download own files; admins may download any. */ public function authorizeDownload(PrintRequest $request, int $userId, bool $userIsPrintAdmin): bool { if ($userIsPrintAdmin) { return true; } return $this->teacherOwnsRequest($request, $userId); } public function absolutePathForDownload(PrintRequest $request): ?string { $fp = trim((string) ($request->file_path ?? '')); if ($fp === '') { return null; } return $this->resolveExistingFilePath($fp); } }