diff --git a/app/Controllers/PrestitiApiController.php b/app/Controllers/PrestitiApiController.php index f28921224..6b3092d59 100644 --- a/app/Controllers/PrestitiApiController.php +++ b/app/Controllers/PrestitiApiController.php @@ -109,13 +109,14 @@ public function list(Request $request, Response $response, mysqli $db): Response $orderDir = 'DESC'; // Map column index to database column - // 0: libro, 1: utente, 2: data_prestito, 3: stato, 4: actions + // 0: libro, 1: utente, 2: data_prestito, 3: data_scadenza, + // 4: stato; columns 5 (PDF) and 6 (actions) are not orderable. $columnMap = [ 0 => 'l.titolo', // Libro 1 => 'utente', // Utente (computed column) 2 => 'p.data_prestito',// Data Prestito - 3 => 'p.stato', // Stato - 4 => 'p.id' // Actions (fallback to id) + 3 => 'p.data_scadenza',// Scadenza + 4 => 'p.stato' // Stato ]; // Parse order parameter from DataTables @@ -135,7 +136,7 @@ public function list(Request $request, Response $response, mysqli $db): Response $param_types .= 'ii'; $sql_prepared = "SELECT p.id, l.titolo AS libro, CONCAT(u.nome,' ',u.cognome) AS utente, - p.data_prestito, p.data_restituzione, p.attivo, p.stato, + p.data_prestito, p.data_scadenza, p.data_restituzione, p.attivo, p.stato, CONCAT(staff.nome,' ',staff.cognome) AS processed_by $base $where_prepared ORDER BY $orderColumn $orderDir LIMIT ?, ?"; @@ -163,6 +164,7 @@ public function list(Request $request, Response $response, mysqli $db): Response 'libro' => $r['libro'] ?? '', 'utente' => $r['utente'] ?? '', 'data_prestito' => $r['data_prestito'] ?? '', + 'data_scadenza' => $r['data_scadenza'] ?? '', 'data_restituzione' => $r['data_restituzione'] ?? '', 'processed_by' => $r['processed_by'] ?? '', 'attivo' => (int)$r['attivo'], diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 08ccfe71a..0530a26ff 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -139,6 +139,49 @@ public function updateGeneral(Request $request, Response $response, mysqli $db): return $this->redirect($response, '/admin/settings?tab=general'); } + /** + * Send a test email with the current mail settings and return a JSON result + * with the real outcome — the specific SMTP error on failure — so the admin + * can diagnose an email configuration without guessing (Nikola #238). + */ + public function sendTestEmail(Request $request, Response $response, mysqli $db): Response + { + $data = (array) $request->getParsedBody(); + + // Recipient: an explicit address from the form, else the logged-in admin's. + $to = trim((string) ($data['test_email'] ?? '')); + if ($to === '') { + $adminId = (int) ($_SESSION['user']['id'] ?? 0); + if ($adminId > 0) { + $stmt = $db->prepare('SELECT email FROM utenti WHERE id = ? LIMIT 1'); + $stmt->bind_param('i', $adminId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + $to = (string) ($row['email'] ?? ''); + } + } + + if ($to === '') { + $response->getBody()->write((string) json_encode([ + 'success' => false, + 'message' => __('Nessun destinatario: inserisci un indirizzo email o assicurati che il tuo account admin ne abbia uno.'), + ])); + return $response->withHeader('Content-Type', 'application/json')->withStatus(400); + } + + $result = \App\Support\Mailer::sendTest($to); + $response->getBody()->write((string) json_encode([ + 'success' => $result['ok'], + 'message' => $result['ok'] + ? sprintf(__('Email di prova inviata a %s. Controlla la casella (anche lo spam).'), $to) + : sprintf(__('Invio fallito: %s'), $result['error']), + ])); + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus($result['ok'] ? 200 : 502); + } + public function updateEmailSettings(Request $request, Response $response, mysqli $db): Response { $data = (array) $request->getParsedBody(); diff --git a/app/Models/SettingsRepository.php b/app/Models/SettingsRepository.php index 1e0a1ca16..386e15556 100644 --- a/app/Models/SettingsRepository.php +++ b/app/Models/SettingsRepository.php @@ -71,6 +71,17 @@ public function get(string $category, string $key, ?string $default = null): ?st return (string)$value; } + /** + * Days-before-expiry warning horizon (Settings → Advanced). Zero is a valid + * value meaning "today only"; negative/corrupt values clamp to 0. Shared by the + * web reminders (NotificationService) and the mobile-api due-soon feed/push so + * the two can never diverge on the setting key, fallback or clamp. + */ + public function daysBeforeExpiryWarning(): int + { + return max(0, (int)($this->get('advanced', 'days_before_expiry_warning', '3') ?? 3)); + } + /** * @return array */ diff --git a/app/Routes/web.php b/app/Routes/web.php index b301e33c9..0f1bb9b87 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -571,6 +571,14 @@ return $controller->updateEmailSettings($request, $response, $db); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/settings/email/test', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + $controller = new SettingsController(); + return $controller->sendTestEmail($request, $response, $db); + })->add(new \App\Middleware\RateLimitMiddleware(5, 60, 'email_test')) // triggers an external SMTP handshake — throttle like the other costly admin routes + ->add(new CsrfMiddleware()) + ->add(new AdminAuthMiddleware()); + $app->post('/admin/settings/contacts', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); $controller = new SettingsController(); diff --git a/app/Support/Mailer.php b/app/Support/Mailer.php index 42bf3816e..c52ab66cc 100644 --- a/app/Support/Mailer.php +++ b/app/Support/Mailer.php @@ -44,10 +44,12 @@ public static function send(string $to, string $subject, string $htmlBody, ?stri $fromName = (string)ConfigStore::get('mail.from_name', 'Biblioteca'); $driver = (string)ConfigStore::get('mail.driver', 'mail'); - if ($driver === 'smtp') { - return self::sendSmtp($to, $subject, $htmlBody, $textBody, $fromEmail, $fromName); - } - if ($driver === 'phpmailer') { + // Both 'smtp' and 'phpmailer' go through PHPMailer. It verifies the SMTP + // handshake, AUTH and recipient and fails loudly — unlike the old hand- + // rolled SMTP path, which never checked server replies and could report + // success on a silently-rejected message (the "SMTP looks fine but no mail + // arrives" class of bug). + if ($driver === 'smtp' || $driver === 'phpmailer') { return self::sendPHPMailer($to, $subject, $htmlBody, $textBody, $fromEmail, $fromName); } return self::sendMail($to, $subject, $htmlBody, $fromEmail, $fromName); @@ -72,77 +74,6 @@ private static function sendMail(string $to, string $subject, string $htmlBody, return @mail($to, self::encodeHeader($subject), $body, implode("\r\n", $headers)); } - private static function sendSmtp(string $to, string $subject, string $htmlBody, ?string $textBody, string $fromEmail, string $fromName): bool - { - $host = (string)ConfigStore::get('mail.smtp.host', 'localhost'); - $port = (int)ConfigStore::get('mail.smtp.port', 587); - $enc = (string)ConfigStore::get('mail.smtp.encryption', 'tls'); - $user = (string)ConfigStore::get('mail.smtp.username', ''); - $pass = (string)ConfigStore::get('mail.smtp.password', ''); - - $transport = ($enc === 'ssl') ? 'ssl://' . $host : $host; - $remote = ($enc === 'tls') ? 'tcp://' . $host . ':' . $port : $transport . ':' . $port; - - $errno = 0; $errstr = ''; - $fp = @stream_socket_client($remote, $errno, $errstr, 10, STREAM_CLIENT_CONNECT); - if (!$fp) return false; - stream_set_timeout($fp, 10); - - $send = function(string $cmd) use ($fp) { - fwrite($fp, $cmd . "\r\n"); - return fgets($fp, 512); - }; - - $read = function() use ($fp) { - return fgets($fp, 512); - }; - - $read(); - $send('EHLO localhost'); - if ($enc === 'tls') { - $send('STARTTLS'); - if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { - fclose($fp); return false; - } - $send('EHLO localhost'); - } - if ($user !== '') { - $send('AUTH LOGIN'); - $send(base64_encode($user)); - $send(base64_encode($pass)); - } - // Validate email addresses to prevent CRLF injection - if (!filter_var($fromEmail, FILTER_VALIDATE_EMAIL) || strpos($fromEmail, "\r") !== false || strpos($fromEmail, "\n") !== false) { - throw new \Exception('Invalid from email address'); - } - $send('MAIL FROM: <' . $fromEmail . '>'); - if (!filter_var($to, FILTER_VALIDATE_EMAIL) || strpos($to, "\r") !== false || strpos($to, "\n") !== false) { - throw new \Exception('Invalid recipient email address'); - } - $send('RCPT TO: <' . $to . '>'); - $send('DATA'); - - // Use more secure boundary generation - $boundary = uniqid('mail_boundary_', true); - $headers = []; - $headers[] = 'From: ' . self::encodeHeader($fromName) . " <{$fromEmail}>"; - $headers[] = 'To: <' . $to . '>'; - $headers[] = 'Subject: ' . self::encodeHeader($subject); - $headers[] = 'MIME-Version: 1.0'; - $headers[] = 'Content-Type: multipart/alternative; boundary="' . $boundary . '"'; - - $text = $textBody ?: strip_tags($htmlBody); - $msg = implode("\r\n", $headers) . "\r\n\r\n"; - $msg .= "--{$boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{$text}\r\n"; - $msg .= "--{$boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{$htmlBody}\r\n"; - $msg .= "--{$boundary}--\r\n."; - - $send($msg); - $send('QUIT'); - fclose($fp); - return true; - } - private static function encodeHeader(string $s): string { if (!preg_match('/^[\x20-\x7E]*$/', $s)) { @@ -151,43 +82,104 @@ private static function encodeHeader(string $s): string return $s; } + /** + * Configure a PHPMailer instance from the stored mail settings (SMTP when a + * host is set, otherwise PHP mail()). Shared by sendPHPMailer() and sendTest(). + */ + private static function configureMailer(\PHPMailer\PHPMailer\PHPMailer $mailer, string $fromEmail, string $fromName): void + { + $mailer->CharSet = 'UTF-8'; + $mailer->setFrom($fromEmail, $fromName); + $mailer->isHTML(true); + + $host = (string) ConfigStore::get('mail.smtp.host', ''); + if ($host !== '') { + $mailer->isSMTP(); + // PHPMailer's default Timeout is 300s: if the SMTP host accepts the TCP + // connection but then stalls, a single send would block the (cron) request + // for 5 minutes. Cap it. + $mailer->Timeout = (int) ConfigStore::get('mail.smtp.timeout', 10); + $mailer->Host = $host; + $mailer->Port = (int) ConfigStore::get('mail.smtp.port', 587); + $enc = (string) ConfigStore::get('mail.smtp.encryption', 'tls'); + if ($enc === 'ssl') { $mailer->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; } + elseif ($enc === 'tls') { $mailer->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS; } + else { $mailer->SMTPSecure = ''; $mailer->SMTPAutoTLS = false; } + $user = (string) ConfigStore::get('mail.smtp.username', ''); + $pass = (string) ConfigStore::get('mail.smtp.password', ''); + if ($user !== '') { $mailer->SMTPAuth = true; $mailer->Username = $user; $mailer->Password = $pass; } + } + } + private static function sendPHPMailer(string $to, string $subject, string $htmlBody, ?string $textBody, string $fromEmail, string $fromName): bool { if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { // Fallback to mail() return self::sendMail($to, $subject, $htmlBody, $fromEmail, $fromName); } + $result = self::dispatchPHPMailer($to, $subject, $htmlBody, $textBody, $fromEmail, $fromName); + if (!$result['ok']) { + SecureLogger::warning('Email send failed: ' . $result['error']); + } + return $result['ok']; + } + + /** + * Build and send one PHPMailer message. Single source of truth for the real + * send path, so the diagnostic sendTest() exercises exactly what production + * uses — if CC/BCC/AltBody/etc. change here they can't silently drift apart. + * Returns the real outcome (the specific error on failure), never throws. + * + * @return array{ok: bool, error: string} + */ + private static function dispatchPHPMailer(string $to, string $subject, string $htmlBody, ?string $textBody, string $fromEmail, string $fromName): array + { try { $mailer = new \PHPMailer\PHPMailer\PHPMailer(true); - $mailer->CharSet = 'UTF-8'; - $mailer->setFrom($fromEmail, $fromName); + self::configureMailer($mailer, $fromEmail, $fromName); $mailer->addAddress($to); $mailer->Subject = $subject; - $mailer->isHTML(true); $mailer->Body = $htmlBody; $mailer->AltBody = $textBody ?: strip_tags($htmlBody); - - // Use SMTP if configured - $host = (string)ConfigStore::get('mail.smtp.host', ''); - if ($host !== '') { - $mailer->isSMTP(); - // PHPMailer's default Timeout is 300s: if the SMTP host accepts the TCP - // connection but then stalls, a single send would block the (cron) request - // for 5 minutes. Cap it — the raw-socket path already uses 10s. - $mailer->Timeout = (int)ConfigStore::get('mail.smtp.timeout', 10); - $mailer->Host = $host; - $mailer->Port = (int)ConfigStore::get('mail.smtp.port', 587); - $enc = (string)ConfigStore::get('mail.smtp.encryption', 'tls'); - if ($enc === 'ssl') { $mailer->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; } - elseif ($enc === 'tls') { $mailer->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS; } - else { $mailer->SMTPSecure = ''; $mailer->SMTPAutoTLS = false; } - $user = (string)ConfigStore::get('mail.smtp.username', ''); - $pass = (string)ConfigStore::get('mail.smtp.password', ''); - if ($user !== '') { $mailer->SMTPAuth = true; $mailer->Username = $user; $mailer->Password = $pass; } - } - return $mailer->send(); + $mailer->send(); + return ['ok' => true, 'error' => '']; } catch (\Throwable $e) { - return false; + return ['ok' => false, 'error' => $e->getMessage()]; } } + + /** + * Send a diagnostic test email with the CURRENT mail settings and return the + * real outcome — the specific error on failure, not just a boolean — so the + * admin can tell a bad SMTP config from a working one. Verifies the SMTP + * handshake/auth/recipient via PHPMailer. + * + * @return array{ok: bool, error: string} + */ + public static function sendTest(string $to): array + { + $fromEmail = (string) ConfigStore::get('mail.from_email', 'no-reply@localhost'); + $fromName = (string) ConfigStore::get('mail.from_name', 'Biblioteca'); + $driver = (string) ConfigStore::get('mail.driver', 'mail'); + + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + return ['ok' => false, 'error' => __('Indirizzo email del destinatario non valido.')]; + } + + $subject = __('Email di prova da Pinakes'); + $html = '

' . __('Questa è un\'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.') . '

'; + + // SMTP / PHPMailer path: reuse the exact real send path so the test is a + // faithful diagnostic, and capture its real handshake/auth/recipient error. + if (($driver === 'smtp' || $driver === 'phpmailer') && class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + return self::dispatchPHPMailer($to, $subject, $html, null, $fromEmail, $fromName); + } + + // Plain mail() path: no detailed error is available, only success/failure. + $ok = self::send($to, $subject, $html); + return $ok + ? ['ok' => true, 'error' => ''] + : ['ok' => false, 'error' => __('La funzione mail() di PHP ha restituito un errore. Verifica la configurazione del server di posta.')]; + } + } diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php index 2e667c864..3d0b13a29 100644 --- a/app/Support/NotificationService.php +++ b/app/Support/NotificationService.php @@ -366,7 +366,7 @@ public function notifyLoanRequest(int $loanId): bool { } /** - * Invia avvisi di scadenza prestiti (3 giorni prima) + * Invia avvisi per prestiti in scadenza entro la finestra configurata. * Uses atomic mark-then-send pattern to prevent duplicate notifications */ public function sendLoanExpirationWarnings(): int { @@ -374,14 +374,17 @@ public function sendLoanExpirationWarnings(): int { try { // Get configured days before expiry warning (default: 3) - $daysBeforeWarning = (int)ConfigStore::get('advanced.days_before_expiry_warning', 3); + $daysBeforeWarning = max(0, (int)ConfigStore::get('advanced.days_before_expiry_warning', 3)); // "Oggi" nel timezone applicativo come parametro bound (M9): CURDATE() // dipende dalla session timezone del client DB, che differiva tra cron // (UTC forzato) e web (nessuna impostazione). $today = DateHelper::today(); - // Get loans expiring in X days + // Include every still-unnotified loan from today through the configured + // warning horizon. An exact "today + X" match misses short loans created + // inside that horizon (especially loans created with due date today). + // Overdue loans remain handled separately below by the overdue workflow. $stmt = $this->db->prepare(" SELECT p.id, p.data_scadenza, l.titolo as libro_titolo, CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email as utente_email, @@ -391,10 +394,10 @@ public function sendLoanExpirationWarnings(): int { JOIN utenti u ON p.utente_id = u.id WHERE p.stato = 'in_corso' AND p.attivo = 1 - AND p.data_scadenza = DATE_ADD(?, INTERVAL ? DAY) + AND p.data_scadenza BETWEEN ? AND DATE_ADD(?, INTERVAL ? DAY) AND (p.warning_sent IS NULL OR p.warning_sent = 0) "); - $stmt->bind_param('ssi', $today, $today, $daysBeforeWarning); + $stmt->bind_param('sssi', $today, $today, $today, $daysBeforeWarning); $stmt->execute(); $result = $stmt->get_result(); @@ -405,6 +408,16 @@ public function sendLoanExpirationWarnings(): int { } $stmt->close(); + // The email template renders in the installation locale (see + // sendWithRetry), so resolve the "oggi" label in that locale too — not + // the caller's session locale (this runs from the admin-login + // maintenance path as well as cron). Computed once for the whole batch. + $installLocale = \App\Support\I18n::getInstallationLocale(); + $sessionLocale = \App\Support\I18n::getLocale(); + \App\Support\I18n::setLocale($installLocale); + $todayLabel = __('oggi'); + \App\Support\I18n::setLocale($sessionLocale); + foreach ($loans as $loan) { // ATOMIC: Mark warning as sent BEFORE sending email // Only proceed if we successfully claimed this loan (affected_rows == 1) @@ -422,21 +435,28 @@ public function sendLoanExpirationWarnings(): int { continue; } + // "Giorni rimasti: 0" reads wrong in the email — show "oggi" for a + // due-today loan, the number of days otherwise (matches the in-app + // notification wording below). + $daysRemaining = (int)$loan['giorni_rimasti']; $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza']), - 'giorni_rimasti' => $loan['giorni_rimasti'] + 'giorni_rimasti' => $daysRemaining === 0 ? $todayLabel : (string)$daysRemaining ]; $emailSent = $this->sendWithRetry($loan['utente_email'], 'loan_expiring_warning', $variables); if ($emailSent) { // Create in-app notification for expiring loan + $notificationMessage = $daysRemaining === 0 + ? sprintf(__('"%s" prestato a %s scade oggi'), $loan['libro_titolo'], $loan['utente_nome']) + : sprintf(__('"%s" prestato a %s scade fra %d giorni'), $loan['libro_titolo'], $loan['utente_nome'], $daysRemaining); $this->createNotification( 'general', __('Prestito in scadenza'), - sprintf(__('"%s" prestato a %s scade fra %d giorni'), $loan['libro_titolo'], $loan['utente_nome'], (int)$loan['giorni_rimasti']), + $notificationMessage, '/admin/loans', (int)$loan['id'] ); diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php index 4e4b7f9ef..a1cfdc744 100644 --- a/app/Views/dashboard/index.php +++ b/app/Views/dashboard/index.php @@ -3,6 +3,7 @@ /** @var array $calendarEvents */ use App\Support\ConfigStore; $isCatalogueMode = ConfigStore::isCatalogueMode(); +$applicationToday = \App\Support\DateHelper::today(); ?>
@@ -212,7 +213,7 @@
@@ -650,14 +651,35 @@ class="w-20 h-28 object-cover rounded-lg shadow-sm" - + + - - - - + + + + + + + + + + + diff --git a/app/Views/prestiti/index.php b/app/Views/prestiti/index.php index db19b9d83..e7c5b4537 100644 --- a/app/Views/prestiti/index.php +++ b/app/Views/prestiti/index.php @@ -28,6 +28,7 @@ function getStatusBadge($status) { return "" . __("Sconosciuto") . ""; } } +$applicationToday = \App\Support\DateHelper::today(); ?> @@ -323,6 +324,7 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te + @@ -331,7 +333,7 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te - +

@@ -348,12 +350,18 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te
-
- -
-
- -
+ + + + + + + @@ -421,6 +429,7 @@ function escHtml(str) { } let currentStatusFilter = ''; + const applicationToday = ; // Initialize DataTable const table = new DataTable('#prestiti-table', { @@ -458,6 +467,22 @@ function escHtml(str) { return `
${dataPrestito}
`; } }, + { + data: 'data_scadenza', + render: function(data, type, row) { + if (!data) { + return `${window.__('N/D')}`; + } + // Same red-highlight rule as the Physical Copies list: an active + // loan (in_corso/in_ritardo) whose due date is today or past. + // Compare against the application's configured timezone, not + // the browser or PHP process timezone. + const isDueNowOrOverdue = data <= applicationToday && Number(row.attivo) === 1 && + (row.stato === 'in_corso' || row.stato === 'in_ritardo'); + const cls = isDueNowOrOverdue ? 'text-red-600 font-semibold' : 'text-gray-700'; + return `${formatDateLocale(data)}`; + } + }, { data: 'stato', className: 'text-center', @@ -506,13 +531,8 @@ className: 'text-right', `; // Show "Conferma Ritiro" button for da_ritirare OR prenotato with today's date - // Use local date (not UTC) to correctly compare with server dates - const now = new Date(); - const today = now.getFullYear() + '-' + - String(now.getMonth() + 1).padStart(2, '0') + '-' + - String(now.getDate()).padStart(2, '0'); const isReadyForPickup = row.stato === 'da_ritirare' || - (row.stato === 'prenotato' && row.data_prestito && row.data_prestito <= today); + (row.stato === 'prenotato' && row.data_prestito && row.data_prestito <= applicationToday); if (isReadyForPickup) { actions += `
+
+

+

+
+ + +
+ +
+
+ diff --git a/cron/automatic-notifications.php b/cron/automatic-notifications.php index 8a402daca..d3455f9c8 100644 --- a/cron/automatic-notifications.php +++ b/cron/automatic-notifications.php @@ -20,6 +20,7 @@ use Dotenv\Dotenv; use App\Support\NotificationService; use App\Controllers\ReservationManager; +use App\Support\HookManager; // ============================================================ // PROCESS LOCK - Prevent concurrent cron executions @@ -159,6 +160,18 @@ function logMessage(string $message): void { $totalSent = $results['expiration_warnings'] + $results['overdue_notifications'] + $results['wishlist_notifications'] + $retriedNotifications; logMessage("Total emails sent: {$totalSent}"); + // Dispatch native mobile push on the same hourly pass as email reminders. + // The daily full-maintenance job also fires this hook, but relying on 06:00 + // alone misses loans created later in the day and may always land inside a + // user's quiet-hours window. PushDispatcher deduplicates event keys, so the + // daily + hourly invocations are safe together. + try { + (new HookManager($db))->doAction('mobile_api.dispatch_push'); + logMessage("Mobile push dispatch completed"); + } catch (Throwable $e) { + logMessage("WARNING: mobile push dispatch failed: " . $e->getMessage()); + } + // NOTE: Daily maintenance tasks (loan state transitions, expiry handling) are now // handled exclusively by full-maintenance.php via MaintenanceService::runAll() // This script focuses only on sending notifications. diff --git a/locale/de_DE.json b/locale/de_DE.json index b81efb85c..e93ce59c8 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -5430,6 +5430,7 @@ "Prenotazione annullata": "Reservierung storniert", "Inviata all'utente quando una prenotazione viene annullata dall'amministrazione.": "Wird an den Benutzer gesendet, wenn eine Reservierung von der Verwaltung storniert wird.", "\"%s\" prestato a %s scade fra %d giorni": "\"%s\" ausgeliehen an %s ist in %d Tagen fällig", + "\"%s\" prestato a %s scade oggi": "\"%s\" ausgeliehen an %s ist heute fällig", "MaintenanceService claim cooldown fallito": "MaintenanceService Claim des Cooldowns fehlgeschlagen", "MaintenanceService errore recupero notifiche prenotazione": "MaintenanceService Fehler beim Abrufen der Reservierungsbenachrichtigungen", "La data di inizio non può essere nel passato": "Das Startdatum darf nicht in der Vergangenheit liegen", @@ -6525,5 +6526,19 @@ "Spaziatura": "Abstand", "Margine interno (mm)": "Innenabstand (mm)", "Il contenuto si adatta automaticamente alle dimensioni dell'etichetta. Aggiungi un margine interno extra per rifinire il layout.": "Der Inhalt passt sich automatisch an die Etikettengröße an. Fügen Sie zusätzlichen Innenabstand hinzu, um das Layout zu optimieren.", - "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "Es sieht so aus, als würden Sie das Docker-Image verwenden: Die Anwendungsdateien gehören zum Image und können über diese Schaltfläche nicht geändert werden. Aktualisieren Sie, indem Sie den Container auf das neue Image umstellen: „docker compose pull && docker compose up -d“ (Ihre Daten in der Datenbank und in den storage/uploads-Volumes bleiben sicher). Die Update-Schaltfläche ist für klassische Installationen/Shared Hosting gedacht, bei denen der Webserver-Benutzer Eigentümer der Dateien ist." -} + "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "Es sieht so aus, als würden Sie das Docker-Image verwenden: Die Anwendungsdateien gehören zum Image und können über diese Schaltfläche nicht geändert werden. Aktualisieren Sie, indem Sie den Container auf das neue Image umstellen: „docker compose pull && docker compose up -d“ (Ihre Daten in der Datenbank und in den storage/uploads-Volumes bleiben sicher). Die Update-Schaltfläche ist für klassische Installationen/Shared Hosting gedacht, bei denen der Webserver-Benutzer Eigentümer der Dateien ist.", + "Nessun destinatario: inserisci un indirizzo email o assicurati che il tuo account admin ne abbia uno.": "Kein Empfänger: Geben Sie eine E-Mail-Adresse ein oder stellen Sie sicher, dass Ihr Admin-Konto eine hat.", + "Email di prova inviata a %s. Controlla la casella (anche lo spam).": "Test-E-Mail an %s gesendet. Prüfen Sie den Posteingang (und den Spam-Ordner).", + "Invio fallito: %s": "Senden fehlgeschlagen: %s", + "Indirizzo email del destinatario non valido.": "Ungültige Empfänger-E-Mail-Adresse.", + "Email di prova da Pinakes": "Test-E-Mail von Pinakes", + "Questa è un'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.": "Dies ist eine Test-E-Mail aus den Pinakes-Einstellungen. Wenn Sie sie erhalten, funktioniert Ihre E-Mail-Konfiguration.", + "La funzione mail() di PHP ha restituito un errore. Verifica la configurazione del server di posta.": "Die PHP-Funktion mail() hat einen Fehler zurückgegeben. Prüfen Sie die Konfiguration des Mailservers.", + "Prova invio": "Sendetest", + "Invia un'email di prova con le impostazioni attuali e vedi subito se funziona o qual è l'errore. Salva prima le impostazioni.": "Senden Sie eine Test-E-Mail mit den aktuellen Einstellungen und sehen Sie sofort, ob es funktioniert oder was der Fehler ist. Speichern Sie zuerst die Einstellungen.", + "Destinatario (vuoto = la tua email admin)": "Empfänger (leer = Ihre Admin-E-Mail)", + "Invia email di prova": "Test-E-Mail senden", + "Invio in corso…": "Wird gesendet…", + "Richiesta non riuscita. Riprova.": "Anfrage fehlgeschlagen. Bitte erneut versuchen.", + "oggi": "heute" +} \ No newline at end of file diff --git a/locale/en_US.json b/locale/en_US.json index a5cbc8072..a69508fdf 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -5430,6 +5430,7 @@ "Prenotazione annullata": "Reservation cancelled", "Inviata all'utente quando una prenotazione viene annullata dall'amministrazione.": "Sent to the user when a reservation is cancelled by the administration.", "\"%s\" prestato a %s scade fra %d giorni": "\"%s\" loaned to %s is due in %d days", + "\"%s\" prestato a %s scade oggi": "\"%s\" loaned to %s is due today", "MaintenanceService claim cooldown fallito": "MaintenanceService claim cooldown failed", "MaintenanceService errore recupero notifiche prenotazione": "MaintenanceService error retrieving reservation notifications", "La data di inizio non può essere nel passato": "The start date cannot be in the past", @@ -6525,5 +6526,19 @@ "Spaziatura": "Spacing", "Margine interno (mm)": "Inner padding (mm)", "Il contenuto si adatta automaticamente alle dimensioni dell'etichetta. Aggiungi un margine interno extra per rifinire il layout.": "Content scales automatically to the label size. Add extra inner padding to fine-tune the layout.", - "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "It looks like you're running the Docker image: the application files belong to the image and can't be modified from this button. Update by moving the container to the new image with “docker compose pull && docker compose up -d” (your data in the database and the storage/uploads volumes stays safe). The update button is meant for classic/shared-hosting installs where the web-server user owns the files." -} + "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "It looks like you're running the Docker image: the application files belong to the image and can't be modified from this button. Update by moving the container to the new image with “docker compose pull && docker compose up -d” (your data in the database and the storage/uploads volumes stays safe). The update button is meant for classic/shared-hosting installs where the web-server user owns the files.", + "Nessun destinatario: inserisci un indirizzo email o assicurati che il tuo account admin ne abbia uno.": "No recipient: enter an email address or make sure your admin account has one.", + "Email di prova inviata a %s. Controlla la casella (anche lo spam).": "Test email sent to %s. Check the inbox (and spam).", + "Invio fallito: %s": "Send failed: %s", + "Indirizzo email del destinatario non valido.": "Invalid recipient email address.", + "Email di prova da Pinakes": "Test email from Pinakes", + "Questa è un'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.": "This is a test email sent from the Pinakes settings. If you received it, your email configuration works.", + "La funzione mail() di PHP ha restituito un errore. Verifica la configurazione del server di posta.": "PHP's mail() function returned an error. Check your mail-server configuration.", + "Prova invio": "Test send", + "Invia un'email di prova con le impostazioni attuali e vedi subito se funziona o qual è l'errore. Salva prima le impostazioni.": "Send a test email with the current settings and see right away whether it works or what the error is. Save the settings first.", + "Destinatario (vuoto = la tua email admin)": "Recipient (empty = your admin email)", + "Invia email di prova": "Send test email", + "Invio in corso…": "Sending…", + "Richiesta non riuscita. Riprova.": "Request failed. Try again.", + "oggi": "today" +} \ No newline at end of file diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 028f4f2fa..799fa8246 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -5430,6 +5430,7 @@ "Prenotazione annullata": "Réservation annulée", "Inviata all'utente quando una prenotazione viene annullata dall'amministrazione.": "Envoyée à l'utilisateur lorsqu'une réservation est annulée par l'administration.", "\"%s\" prestato a %s scade fra %d giorni": "\"%s\" emprunté par %s arrive à échéance dans %d jours", + "\"%s\" prestato a %s scade oggi": "\"%s\" emprunté par %s arrive à échéance aujourd'hui", "MaintenanceService claim cooldown fallito": "MaintenanceService échec du claim du cooldown", "MaintenanceService errore recupero notifiche prenotazione": "MaintenanceService erreur lors de la récupération des notifications de réservation", "La data di inizio non può essere nel passato": "La date de début ne peut pas être dans le passé", @@ -6525,5 +6526,19 @@ "Spaziatura": "Espacement", "Margine interno (mm)": "Marge intérieure (mm)", "Il contenuto si adatta automaticamente alle dimensioni dell'etichetta. Aggiungi un margine interno extra per rifinire il layout.": "Le contenu s'adapte automatiquement à la taille de l'étiquette. Ajoutez une marge intérieure supplémentaire pour affiner la mise en page.", - "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "Il semble que vous utilisiez l'image Docker : les fichiers de l'application appartiennent à l'image et ne peuvent pas être modifiés depuis ce bouton. Mettez à jour en déplaçant le conteneur vers la nouvelle image avec « docker compose pull && docker compose up -d » (vos données dans la base et dans les volumes storage/uploads restent en sécurité). Le bouton de mise à jour est prévu pour les installations classiques/hébergement mutualisé où l'utilisateur du serveur web est propriétaire des fichiers." -} + "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "Il semble que vous utilisiez l'image Docker : les fichiers de l'application appartiennent à l'image et ne peuvent pas être modifiés depuis ce bouton. Mettez à jour en déplaçant le conteneur vers la nouvelle image avec « docker compose pull && docker compose up -d » (vos données dans la base et dans les volumes storage/uploads restent en sécurité). Le bouton de mise à jour est prévu pour les installations classiques/hébergement mutualisé où l'utilisateur du serveur web est propriétaire des fichiers.", + "Nessun destinatario: inserisci un indirizzo email o assicurati che il tuo account admin ne abbia uno.": "Aucun destinataire : saisissez une adresse e-mail ou assurez-vous que votre compte admin en possède une.", + "Email di prova inviata a %s. Controlla la casella (anche lo spam).": "E-mail de test envoyé à %s. Vérifiez la boîte de réception (et les spams).", + "Invio fallito: %s": "Échec de l'envoi : %s", + "Indirizzo email del destinatario non valido.": "Adresse e-mail du destinataire non valide.", + "Email di prova da Pinakes": "E-mail de test de Pinakes", + "Questa è un'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.": "Ceci est un e-mail de test envoyé depuis les paramètres de Pinakes. Si vous le recevez, votre configuration e-mail fonctionne.", + "La funzione mail() di PHP ha restituito un errore. Verifica la configurazione del server di posta.": "La fonction mail() de PHP a renvoyé une erreur. Vérifiez la configuration du serveur de messagerie.", + "Prova invio": "Test d'envoi", + "Invia un'email di prova con le impostazioni attuali e vedi subito se funziona o qual è l'errore. Salva prima le impostazioni.": "Envoyez un e-mail de test avec les paramètres actuels et voyez immédiatement si cela fonctionne ou quelle est l'erreur. Enregistrez d'abord les paramètres.", + "Destinatario (vuoto = la tua email admin)": "Destinataire (vide = votre e-mail admin)", + "Invia email di prova": "Envoyer un e-mail de test", + "Invio in corso…": "Envoi en cours…", + "Richiesta non riuscita. Riprova.": "La requête a échoué. Réessayez.", + "oggi": "aujourd'hui" +} \ No newline at end of file diff --git a/locale/it_IT.json b/locale/it_IT.json index 0af146675..af555b4dd 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -5430,6 +5430,7 @@ "Prenotazione annullata": "Prenotazione annullata", "Inviata all'utente quando una prenotazione viene annullata dall'amministrazione.": "Inviata all'utente quando una prenotazione viene annullata dall'amministrazione.", "\"%s\" prestato a %s scade fra %d giorni": "\"%s\" prestato a %s scade fra %d giorni", + "\"%s\" prestato a %s scade oggi": "\"%s\" prestato a %s scade oggi", "MaintenanceService claim cooldown fallito": "MaintenanceService claim cooldown fallito", "MaintenanceService errore recupero notifiche prenotazione": "MaintenanceService errore recupero notifiche prenotazione", "La data di inizio non può essere nel passato": "La data di inizio non può essere nel passato", @@ -6525,5 +6526,19 @@ "Spaziatura": "Spaziatura", "Margine interno (mm)": "Margine interno (mm)", "Il contenuto si adatta automaticamente alle dimensioni dell'etichetta. Aggiungi un margine interno extra per rifinire il layout.": "Il contenuto si adatta automaticamente alle dimensioni dell'etichetta. Aggiungi un margine interno extra per rifinire il layout.", - "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file." -} + "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.": "Sembra che tu stia eseguendo l'immagine Docker: i file dell'applicazione appartengono all'immagine e non sono modificabili da questo pulsante. Aggiorna spostando il container alla nuova immagine con «docker compose pull && docker compose up -d» (i tuoi dati nel database e nei volumi storage/uploads restano al sicuro). Il pulsante di aggiornamento è pensato per installazioni classiche/hosting condiviso dove l'utente del web server è proprietario dei file.", + "Nessun destinatario: inserisci un indirizzo email o assicurati che il tuo account admin ne abbia uno.": "Nessun destinatario: inserisci un indirizzo email o assicurati che il tuo account admin ne abbia uno.", + "Email di prova inviata a %s. Controlla la casella (anche lo spam).": "Email di prova inviata a %s. Controlla la casella (anche lo spam).", + "Invio fallito: %s": "Invio fallito: %s", + "Indirizzo email del destinatario non valido.": "Indirizzo email del destinatario non valido.", + "Email di prova da Pinakes": "Email di prova da Pinakes", + "Questa è un'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.": "Questa è un'email di prova inviata dalle impostazioni di Pinakes. Se la ricevi, la configurazione email funziona.", + "La funzione mail() di PHP ha restituito un errore. Verifica la configurazione del server di posta.": "La funzione mail() di PHP ha restituito un errore. Verifica la configurazione del server di posta.", + "Prova invio": "Prova invio", + "Invia un'email di prova con le impostazioni attuali e vedi subito se funziona o qual è l'errore. Salva prima le impostazioni.": "Invia un'email di prova con le impostazioni attuali e vedi subito se funziona o qual è l'errore. Salva prima le impostazioni.", + "Destinatario (vuoto = la tua email admin)": "Destinatario (vuoto = la tua email admin)", + "Invia email di prova": "Invia email di prova", + "Invio in corso…": "Invio in corso…", + "Richiesta non riuscita. Riprova.": "Richiesta non riuscita. Riprova.", + "oggi": "oggi" +} \ No newline at end of file diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index 0793347c2..0d3240dfb 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -708,11 +708,11 @@ public function notifications(Request $request, ResponseInterface $response): Re // Domain "today" from DateHelper::today() (app timezone), so the // due/overdue day boundary matches the web/cron pipeline. $today = DateHelper::today(); - // Due-soon window from settings (fallback 3 days), same idea as the web reminders. - $dueSoonDays = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'reminder_days_before', '3') ?? 3); - if ($dueSoonDays < 1) { - $dueSoonDays = 3; - } + // Single source of truth for the horizon (key + fallback + clamp), shared + // with the web reminders and the push dispatcher. The former + // loans.reminder_days_before key never existed, so the mobile feed used to + // stay pinned to its fallback even after an admin changed the UI. + $dueSoonDays = (new \App\Models\SettingsRepository($this->db))->daysBeforeExpiryWarning(); $dueSoonLimit = date('Y-m-d', strtotime($today . " +{$dueSoonDays} days")); // Loans due soon (active, in-progress, not yet overdue). @@ -912,14 +912,23 @@ private function fetchScoped(string $sql, int $userId): array */ private function mapLoan(array $r): array { + $status = (string) ($r['stato'] ?? ''); + $dueAt = $this->nullableString($r['data_scadenza'] ?? null); + return [ 'id' => (int) $r['id'], 'book_id' => (int) $r['libro_id'], 'title' => (string) ($r['titolo'] ?? ''), 'cover_url' => absoluteUrl($this->coverPath($r['copertina_url'] ?? null)), - 'status' => (string) ($r['stato'] ?? ''), + 'status' => $status, 'loaned_at' => $this->nullableString($r['data_prestito'] ?? null), - 'due_at' => $this->nullableString($r['data_scadenza'] ?? null), + 'due_at' => $dueAt, + // Server-authoritative visibility cue: the Android device may be in a + // different timezone from the library, so it must not derive "today" + // from LocalDate.now(). The badge still uses the raw status above. + 'due_attention' => $dueAt !== null + && in_array($status, ['in_corso', 'in_ritardo'], true) + && $dueAt <= DateHelper::today(), 'returned_at' => $this->nullableString($r['data_restituzione'] ?? null), 'renewals' => isset($r['renewals']) ? (int) $r['renewals'] : null, ]; diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index d633e3ad8..1ea97a806 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -1112,6 +1112,10 @@ private function loanItemSchema(): array 'status' => ['type' => 'string', 'description' => 'Raw prestiti.stato value.'], 'loaned_at' => ['type' => 'string', 'format' => 'date', 'nullable' => true], 'due_at' => ['type' => 'string', 'format' => 'date', 'nullable' => true], + 'due_attention' => [ + 'type' => 'boolean', + 'description' => 'True when an active in-progress/overdue loan is due today or earlier in the application timezone.', + ], 'returned_at' => ['type' => 'string', 'format' => 'date', 'nullable' => true], 'renewals' => ['type' => 'integer', 'nullable' => true], ], diff --git a/storage/plugins/mobile-api/src/Push/PushDispatcher.php b/storage/plugins/mobile-api/src/Push/PushDispatcher.php index c9c527642..ebac967d6 100644 --- a/storage/plugins/mobile-api/src/Push/PushDispatcher.php +++ b/storage/plugins/mobile-api/src/Push/PushDispatcher.php @@ -4,6 +4,7 @@ namespace App\Plugins\MobileApi\Push; +use App\Support\DateHelper; use App\Support\SecureLogger; use mysqli; @@ -66,11 +67,13 @@ public function dispatch(): array ]; try { - // UTC so quiet-hours and "today" comparisons are deterministic. - $this->db->query("SET SESSION time_zone = '+00:00'"); + // Bind the application-domain date explicitly. CURDATE() would follow + // the DB session timezone (this class used to force UTC), disagreeing + // with the web/email pipeline around the library's midnight. + $today = DateHelper::today(); - $counters['loan_due'] = $this->sweepLoanDue($counters); - $counters['loan_overdue'] = $this->sweepLoanOverdue($counters); + $counters['loan_due'] = $this->sweepLoanDue($counters, $today); + $counters['loan_overdue'] = $this->sweepLoanOverdue($counters, $today); $counters['reservation_ready'] = $this->sweepReservationReady($counters); $counters['book_available'] = $this->sweepBookAvailable($counters); } catch (\Throwable $e) { @@ -89,7 +92,7 @@ public function dispatch(): array * * @param array{loan_due:int,loan_overdue:int,reservation_ready:int,book_available:int,sent:int,skipped:int} $counters */ - private function sweepLoanDue(array &$counters): int + private function sweepLoanDue(array &$counters, string $today): int { $days = $this->dueSoonDays(); $events = 0; @@ -98,13 +101,13 @@ private function sweepLoanDue(array &$counters): int FROM prestiti pr JOIN libri l ON l.id = pr.libro_id AND l.deleted_at IS NULL WHERE pr.attivo = 1 AND pr.stato = 'in_corso' - AND pr.data_scadenza >= CURDATE() - AND pr.data_scadenza <= DATE_ADD(CURDATE(), INTERVAL ? DAY)"; + AND pr.data_scadenza >= ? + AND pr.data_scadenza <= DATE_ADD(?, INTERVAL ? DAY)"; $stmt = $this->db->prepare($sql); if ($stmt === false) { return 0; } - $stmt->bind_param('i', $days); + $stmt->bind_param('ssi', $today, $today, $days); $stmt->execute(); $res = $stmt->get_result(); $rows = ($res instanceof \mysqli_result) ? $res->fetch_all(MYSQLI_ASSOC) : []; @@ -142,7 +145,7 @@ private function sweepLoanDue(array &$counters): int /** * @param array{loan_due:int,loan_overdue:int,reservation_ready:int,book_available:int,sent:int,skipped:int} $counters */ - private function sweepLoanOverdue(array &$counters): int + private function sweepLoanOverdue(array &$counters, string $today): int { $events = 0; @@ -150,9 +153,16 @@ private function sweepLoanOverdue(array &$counters): int FROM prestiti pr JOIN libri l ON l.id = pr.libro_id AND l.deleted_at IS NULL WHERE pr.attivo = 1 - AND (pr.stato = 'in_ritardo' OR (pr.stato = 'in_corso' AND pr.data_scadenza < CURDATE()))"; - $res = $this->db->query($sql); + AND (pr.stato = 'in_ritardo' OR (pr.stato = 'in_corso' AND pr.data_scadenza < ?))"; + $stmt = $this->db->prepare($sql); + if ($stmt === false) { + return 0; + } + $stmt->bind_param('s', $today); + $stmt->execute(); + $res = $stmt->get_result(); $rows = ($res instanceof \mysqli_result) ? $res->fetch_all(MYSQLI_ASSOC) : []; + $stmt->close(); foreach ($rows as $r) { $userId = (int) $r['utente_id']; @@ -565,11 +575,11 @@ private function releaseClaim(int $userId, string $eventKey): void private function dueSoonDays(): int { try { - $days = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'reminder_days_before', '3') ?? 3); + // Single source of truth for the horizon (key + fallback + clamp), shared + // with the web reminders and the mobile-api loan feed. + return (new \App\Models\SettingsRepository($this->db))->daysBeforeExpiryWarning(); } catch (\Throwable $e) { - $days = 3; + return 3; } - - return $days >= 1 ? $days : 3; } } diff --git a/tests/email-notifications.spec.js b/tests/email-notifications.spec.js index 2276ec0e5..8e1373dd7 100644 --- a/tests/email-notifications.spec.js +++ b/tests/email-notifications.spec.js @@ -623,6 +623,34 @@ test.describe.serial('Email Notifications E2E', () => { dbQuery(`DELETE FROM prestiti WHERE id = ${loanId}`); }); + test('B.9b — Loan due today receives the expiration warning', async () => { + await clearMailpit(); + + const userId = dbQuery(`SELECT id FROM utenti WHERE email = '${TEST_USER_EMAIL}' LIMIT 1`); + const bookId = dbQuery("SELECT id FROM libri WHERE deleted_at IS NULL LIMIT 1"); + const today = todayISO(); + const startDate = dateOffset(-1); + const loanId = dbQuery(`INSERT INTO prestiti (libro_id, utente_id, stato, data_prestito, data_scadenza, attivo, warning_sent, overdue_notification_sent, created_at) + VALUES (${bookId}, ${userId}, 'in_corso', '${startDate}', '${today}', 1, 0, 0, NOW()); SELECT LAST_INSERT_ID()`); + + await withAdminPage(browserRef, async (page) => { + await page.goto(`${BASE}/admin/integrity`, { waitUntil: 'domcontentloaded' }); + const csrfToken = await getCsrfToken(page); + const maintenanceRes = await page.request.post(`${BASE}/admin/maintenance/perform`, { + form: { csrf_token: csrfToken || '' }, + }); + expect(maintenanceRes.status()).toBeLessThan(500); + }); + + const warningMail = await waitForMail(`to:${TEST_USER_EMAIL}`); + expect(warningMail).toBeTruthy(); + const msg = await getMessage(warningMail.ID); + expect(msg.Subject.toLowerCase()).toMatch(/scad|expir|promemoria|warning/); + expect(dbQuery(`SELECT warning_sent FROM prestiti WHERE id = ${loanId}`)).toBe('1'); + + dbQuery(`DELETE FROM prestiti WHERE id = ${loanId}`); + }); + // ── B.10 + B.11: Loan overdue (user + admin) ────────────────── test('B.10+B.11 — Overdue loan notifications (user + admin)', async () => { await clearMailpit(); diff --git a/tests/loan-due-visibility-email.unit.php b/tests/loan-due-visibility-email.unit.php new file mode 100644 index 000000000..1e089f600 --- /dev/null +++ b/tests/loan-due-visibility-email.unit.php @@ -0,0 +1,59 @@ +\s*'p\\.data_scadenza'/", $api) === 1 + && preg_match("/4\s*=>\s*'p\\.stato'/", $api) === 1, + 'DataTables due-date and status columns map to their real SQL fields' +); +$check( + str_contains($loansView, 'DateHelper::today()') + && str_contains($loansView, 'data <= applicationToday') + && !str_contains($loansView, "strtotime(date('Y-m-d'))"), + 'loan list highlighting uses the application date in PHP and JavaScript' +); +$check( + str_contains($dashboardView, 'DateHelper::today()') + && !str_contains($dashboardView, "strtotime(date('Y-m-d'))"), + 'dashboard due-date highlighting uses the application timezone' +); + +foreach (['it_IT', 'en_US', 'fr_FR', 'de_DE'] as $locale) { + $catalogue = json_decode((string)file_get_contents($root . "/locale/{$locale}.json"), true); + $check( + is_array($catalogue) && isset($catalogue['"%s" prestato a %s scade oggi']), + "{$locale} translates the due-today notification" + ); +} + +echo "\nPassed: {$passed} Failed: {$failed}\n"; +exit($failed > 0 ? 1 : 0); diff --git a/tests/mobile-api-behaviors.spec.js b/tests/mobile-api-behaviors.spec.js index fd4377965..ddb9e059c 100644 --- a/tests/mobile-api-behaviors.spec.js +++ b/tests/mobile-api-behaviors.spec.js @@ -499,11 +499,54 @@ test.describe('Loans envelope', () => { expect(mine, 'seeded loan present in active[]').toBeTruthy(); expect(mine.status).toBe('in_corso'); expect(mine.due_at, 'due_at present on active loan').toBeTruthy(); + expect(mine.due_attention, 'future due date needs no urgent styling').toBe(false); } finally { dbExec(`DELETE FROM prestiti WHERE id = ${loanId}`); } }); + test('20b) a loan due today keeps status=in_corso but exposes due_attention=true', async ({ request }) => { + const book = pickAvailableBook(); + dbExec(` + INSERT INTO prestiti (libro_id, utente_id, copia_id, data_prestito, data_scadenza, stato, attivo, origine, renewals) + VALUES (${book}, ${ctx.adminId}, NULL, ${sqlStr(todayYmd())}, ${sqlStr(todayYmd())}, 'in_corso', 1, 'diretto', 0)`); + const loanId = dbInt(`SELECT id FROM prestiti WHERE libro_id = ${book} AND utente_id = ${ctx.adminId} ORDER BY id DESC LIMIT 1`); + try { + const active = (await jsonOf(await call(request, 'GET', '/me/loans', { token: ctx.adminToken }))).data.active; + const mine = active.find((l) => l.id === loanId); + expect(mine, 'due-today loan present in active[]').toBeTruthy(); + expect(mine.status).toBe('in_corso'); + expect(mine.due_attention).toBe(true); + } finally { + dbExec(`DELETE FROM prestiti WHERE id = ${loanId}`); + } + }); + + test('20c) notification feed honors advanced.days_before_expiry_warning', async ({ request }) => { + const oldValue = dbScalar("SELECT setting_value FROM system_settings WHERE category='advanced' AND setting_key='days_before_expiry_warning' LIMIT 1"); + const hadValue = dbInt("SELECT COUNT(*) FROM system_settings WHERE category='advanced' AND setting_key='days_before_expiry_warning'") > 0; + const book = pickAvailableBook(); + dbExec(` + INSERT INTO system_settings (category, setting_key, setting_value) + VALUES ('advanced', 'days_before_expiry_warning', '0') + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`); + dbExec(` + INSERT INTO prestiti (libro_id, utente_id, copia_id, data_prestito, data_scadenza, stato, attivo, origine, renewals) + VALUES (${book}, ${ctx.adminId}, NULL, ${sqlStr(todayYmd())}, ${sqlStr(plusDays(1))}, 'in_corso', 1, 'diretto', 0)`); + const loanId = dbInt(`SELECT id FROM prestiti WHERE libro_id = ${book} AND utente_id = ${ctx.adminId} ORDER BY id DESC LIMIT 1`); + try { + const feed = (await jsonOf(await call(request, 'GET', '/me/notifications', { token: ctx.adminToken }))).data; + expect(feed.some((n) => n.id === `loan:${loanId}`), 'zero-day horizon includes today only').toBe(false); + } finally { + dbExec(`DELETE FROM prestiti WHERE id = ${loanId}`); + if (hadValue) { + dbExec(`UPDATE system_settings SET setting_value = ${sqlStr(oldValue)} WHERE category='advanced' AND setting_key='days_before_expiry_warning'`); + } else { + dbExec("DELETE FROM system_settings WHERE category='advanced' AND setting_key='days_before_expiry_warning'"); + } + } + }); + test('21) a seeded restituito loan appears in history[]', async ({ request }) => { const book = pickAvailableBook(); dbExec(` diff --git a/tests/mobile-due-push-alignment.unit.php b/tests/mobile-due-push-alignment.unit.php new file mode 100644 index 000000000..92a6304c7 --- /dev/null +++ b/tests/mobile-due-push-alignment.unit.php @@ -0,0 +1,57 @@ +daysBeforeExpiryWarning()') === 2 + && !str_contains($actions . $dispatcher, "get('advanced', 'days_before_expiry_warning'") + && !str_contains($actions . $dispatcher, "get('loans', 'reminder_days_before'"), + 'mobile feed and push share the core expiration-warning horizon helper' +); +$check( + str_contains($dispatcher, '$today = DateHelper::today()') + && !str_contains($dispatcher, '>= CURDATE()') + && !str_contains($dispatcher, '< CURDATE()') + && !str_contains($dispatcher, "SET SESSION time_zone = '+00:00'"), + 'push date boundaries use the application timezone' +); +$check( + str_contains($dispatcher, "bind_param('ssi', \$today, \$today, \$days)") + && str_contains($dispatcher, "bind_param('s', \$today)"), + 'due and overdue push queries bind the authoritative application date' +); +$check( + str_contains($actions, "'due_attention'") && str_contains($openApi, "'due_attention'"), + 'loan API and OpenAPI expose the server-authoritative due-date cue' +); +$check( + str_contains($hourlyCron, "doAction('mobile_api.dispatch_push')"), + 'hourly notification cron dispatches native mobile push' +); + +echo "\nPassed: {$passed} Failed: {$failed}\n"; +exit($failed > 0 ? 1 : 0);