Files
alrahma_sunday_school_api/app/Services/PrintRequests/PrintRequestsPortalService.php
T
2026-06-09 01:03:53 -04:00

581 lines
21 KiB
PHP

<?php
namespace App\Services\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 \App\Controllers\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);
}
}