582 lines
21 KiB
PHP
582 lines
21 KiB
PHP
<?php
|
|
|
|
namespace App\Services\PrintRequests;
|
|
|
|
use App\Controllers\PrintRequests;
|
|
use App\Models\AdminNotificationSubject;
|
|
use App\Models\ClassSection;
|
|
use App\Models\Configuration;
|
|
use App\Models\PrintRequest;
|
|
use App\Models\User;
|
|
use App\Services\ApplicationUrlService;
|
|
use App\Services\Email\EmailDispatchService;
|
|
use Carbon\CarbonInterface;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* legacy {@see PrintRequests} parity (SPA JSON + uploads).
|
|
*/
|
|
class PrintRequestsPortalService
|
|
{
|
|
public function __construct(
|
|
private EmailDispatchService $mail,
|
|
private ApplicationUrlService $urls,
|
|
) {}
|
|
|
|
public function storageDirectory(): string
|
|
{
|
|
return storage_path('app/private/print_requests');
|
|
}
|
|
|
|
public function ensureStorageDirectoryExists(): void
|
|
{
|
|
$dir = $this->storageDirectory();
|
|
if (! is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
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<string>, times: list<string>}
|
|
*/
|
|
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<array<string, mixed>>
|
|
*/
|
|
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<array<string, mixed>>
|
|
*/
|
|
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<string, mixed> $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 = '<p>'.e($eventConfig['intro']).'</p>'
|
|
.'<p><strong>From:</strong> '.e($teacherName).'</p>'
|
|
.($className !== '' ? '<p><strong>Class:</strong> '.e($className).'</p>' : '')
|
|
.(! empty($requestData['num_copies']) ? '<p><strong>Copies:</strong> '.(int) $requestData['num_copies'].'</p>' : '')
|
|
.(! empty($requestData['required_by']) ? '<p><strong>Needed by:</strong> '.e((string) $requestData['required_by']).'</p>' : '')
|
|
.(! empty($requestData['page_selection']) ? '<p><strong>Pages:</strong> '.e((string) $requestData['page_selection']).'</p>' : '')
|
|
.(! empty($requestData['pickup_method']) ? '<p><strong>Pickup Method:</strong> '.e((string) $requestData['pickup_method']).'</p>' : '')
|
|
.($isCopyRequest ? '' : '<p><a href="'.e($actionUrl).'">View print requests</a></p>');
|
|
|
|
$this->mail->send($email, $subject, $body, 'notifications');
|
|
}
|
|
} catch (\Throwable $e) {
|
|
logger()->error('Print request notification failed: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<string, list<string>>
|
|
*/
|
|
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<array<string, mixed>>, sundays: list<string>, times: list<string>, 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<string, mixed> $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 = $this->safeExtensionForUpload($file);
|
|
$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<string, mixed> $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 = $this->safeExtensionForUpload($newFile);
|
|
$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();
|
|
}
|
|
|
|
private function safeExtensionForUpload(UploadedFile $file): string
|
|
{
|
|
$allowed = [
|
|
'application/pdf' => 'pdf',
|
|
'image/jpeg' => 'jpg',
|
|
'image/png' => 'png',
|
|
'text/plain' => 'txt',
|
|
'application/msword' => 'doc',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
|
];
|
|
|
|
$mime = strtolower((string) $file->getMimeType());
|
|
if (! isset($allowed[$mime])) {
|
|
throw new \InvalidArgumentException('Unsupported upload type.');
|
|
}
|
|
|
|
if ((int) $file->getSize() > 5 * 1024 * 1024) {
|
|
throw new \InvalidArgumentException('Uploaded file too large. Max 5MB.');
|
|
}
|
|
|
|
return $allowed[$mime];
|
|
}
|
|
|
|
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<string, mixed> $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);
|
|
}
|
|
}
|