From 991cc6f6b225a15e20c67b48c71e86dad956cd76 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 17 Aug 2026 22:30:48 +0000 Subject: [PATCH 01/19] feat(loans): overdue recalls (solleciti) and email loan receipt (#360) Recall (sollecito) for overdue loans: - prestiti.recall_count / last_recall_at track repeated recalls (migrate_0.7.62.sql, schema.sql, runtime self-healing in NotificationService::addNotificationColumns). - Automatic recalls: NotificationService::sendLoanRecalls() runs in runAutomaticNotifications() and re-sends the sollecito every loans.recall_interval_days of overdue, up to loans.recall_max_count times, gated by loans.recall_auto_enabled (new Settings > Loans section, off by default). Atomic claim-then-send with revert on failure, same pattern as the overdue notifier. - Manual recalls: POST /admin/loans/{id}/recall (button on the loan detail page) and POST /admin/loans/bulk-recall (loans list bulk bar, same selection as bulk-extend). Manual sends skip the schedule but share the counter; non-overdue / email-less loans are skipped and reported. - New editable email template loan_recall_notification (it/en/de/fr/da) with {{numero_sollecito}} placeholder + recall_number English alias. Email the loan receipt PDF: - EmailService::sendTemplate/sendEmail accept in-memory attachments (addStringAttachment; cleared around every send since the PHPMailer instance is reused). - NotificationService::sendLoanReceiptEmail() attaches the same PDF downloadPdf() serves to the new loan_receipt_email template; POST /admin/loans/{id}/email-pdf + "Invia Ricevuta via Email" button on the loan detail page. Also: cron log line for recalls, locale strings for all five languages, DB-free contract test tests/loan-recall-360.unit.php. Closes #360 --- CHANGELOG.md | 21 ++ app/Controllers/PrestitiController.php | 85 ++++++ app/Controllers/SettingsController.php | 14 + app/Routes/web.php | 24 ++ app/Support/EmailService.php | 32 ++- app/Support/NotificationService.php | 249 ++++++++++++++++++ app/Support/SettingsMailTemplates.php | 46 ++++ app/Support/mail_templates/da_DK.php | 30 +++ app/Support/mail_templates/de_DE.php | 30 +++ app/Support/mail_templates/en_US.php | 30 +++ app/Support/mail_templates/fr_FR.php | 30 +++ app/Views/prestiti/dettagli_prestito.php | 121 ++++++++- app/Views/prestiti/index.php | 59 ++++- app/Views/settings/loans-tab.php | 77 ++++++ cron/automatic-notifications.php | 3 +- .../database/migrations/migrate_0.7.62.sql | 40 +++ installer/database/schema.sql | 2 + locale/da_DK.json | 45 +++- locale/de_DE.json | 45 +++- locale/en_US.json | 45 +++- locale/fr_FR.json | 45 +++- locale/it_IT.json | 45 +++- tests/loan-recall-360.unit.php | 146 ++++++++++ 23 files changed, 1252 insertions(+), 12 deletions(-) create mode 100644 installer/database/migrations/migrate_0.7.62.sql create mode 100644 tests/loan-recall-360.unit.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 577cc7ac2..605cb0d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here. +## [Unreleased] + +Overdue-loan recalls (solleciti) and emailing the loan receipt (#360). + +### Features + +- **Loan recalls (solleciti)**: overdue loans can now be chased beyond the + single overdue notification. Automatic recalls repeat at a configurable + interval up to a configurable cap (Settings → Loans → "Solleciti automatici", + off by default; sent by the notifications cron or on admin login). Staff can + also send a manual recall for one loan from the loan detail page, or for many + at once from the loans list via the bulk action bar — manual recalls ignore + the automatic schedule but share the same per-loan counter + (`prestiti.recall_count` / `last_recall_at`, added by + `migrate_0.7.62.sql` and self-healed at runtime). New editable email + template `loan_recall_notification` in all five locales. +- **Email the loan receipt PDF**: next to "Scarica Ricevuta PDF", the loan + detail page now has "Invia Ricevuta via Email", which sends the same PDF as + an attachment to the loan's user (new editable template + `loan_receipt_email`; the mailer gained in-memory attachment support). + ## [0.7.61] Physical-copy management from the book summary, with the whole holding and diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index a3a668403..4dfa3525b 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -2159,6 +2159,91 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res ->withHeader('Pragma', 'no-cache'); } + /** + * #360: manual recall (sollecito) for a single overdue loan — JSON endpoint + * behind the "Invia Sollecito" button on the loan detail page. + */ + public function sendRecall(Request $request, Response $response, mysqli $db, int $id): Response + { + if ($guard = $this->guardStaffAccess($response)) { + return $guard; + } + // CSRF validated by CsrfMiddleware. + + $result = (new NotificationService($db))->sendManualRecall($id); + + $response->getBody()->write((string) json_encode([ + 'success' => (bool) $result['success'], + 'message' => (string) $result['message'], + ])); + return $response->withHeader('Content-Type', 'application/json'); + } + + /** + * #360: bulk recall — sends the sollecito email to every selected loan that + * is genuinely overdue. Mirrors bulkExtend()'s form-POST + redirect-flash + * shape; per-loan claims live in NotificationService::sendManualRecall(), + * so a loan that isn't overdue, has no email, or fails to send is skipped + * (counted) without aborting the batch. + */ + private const BULK_RECALL_MAX_LOANS = 500; + + public function bulkRecall(Request $request, Response $response, mysqli $db): Response + { + if ($guard = $this->guardStaffAccess($response)) { + return $guard; + } + $data = (array) $request->getParsedBody(); + // CSRF validated by CsrfMiddleware. + + $ids = array_values(array_unique(array_filter( + array_map('intval', (array) ($data['ids'] ?? [])), + static fn (int $i): bool => $i > 0 + ))); + + $backUrl = url('/admin/loans'); + if ($ids === [] || count($ids) > self::BULK_RECALL_MAX_LOANS) { + return $response->withHeader('Location', $backUrl . '?error=bulk_recall_invalid')->withStatus(302); + } + + $service = new NotificationService($db); + $sent = 0; + $skipped = 0; + foreach ($ids as $loanId) { + $result = $service->sendManualRecall($loanId); + if ($result['success']) { + $sent++; + } else { + $skipped++; + } + } + + return $response + ->withHeader('Location', $backUrl . '?bulk_recalled=' . $sent . '&bulk_recall_skipped=' . $skipped) + ->withStatus(302); + } + + /** + * #360: email the loan receipt PDF to the loan's user — JSON endpoint + * behind the "Invia Ricevuta via Email" button on the loan detail page. + * The attached document is the same one downloadPdf() serves. + */ + public function emailPdf(Request $request, Response $response, mysqli $db, int $id): Response + { + if ($guard = $this->guardStaffAccess($response)) { + return $guard; + } + // CSRF validated by CsrfMiddleware. + + $result = (new NotificationService($db))->sendLoanReceiptEmail($id); + + $response->getBody()->write((string) json_encode([ + 'success' => (bool) $result['success'], + 'message' => (string) $result['message'], + ])); + return $response->withHeader('Content-Type', 'application/json'); + } + private function guardStaffAccess(Response $response): ?Response { $role = $_SESSION['user']['tipo_utente'] ?? ''; diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 4cc0d72e1..d18972447 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -1294,6 +1294,10 @@ private function resolveLoansSettings(SettingsRepository $repository): array 'max_active_loans_per_user' => (int) ($repository->get('loans', 'max_active_loans_per_user', '0') ?? 0), 'max_loan_duration_days' => (int) ($repository->get('loans', 'max_loan_duration_days', '90') ?? 90), 'auto_approve_requests' => $repository->autoApproveLoanRequests(), + // #360: automatic recall (sollecito) schedule for overdue loans. + 'recall_auto_enabled' => (string) ($repository->get('loans', 'recall_auto_enabled', '0') ?? '0') === '1', + 'recall_interval_days' => (int) ($repository->get('loans', 'recall_interval_days', '7') ?? 7), + 'recall_max_count' => (int) ($repository->get('loans', 'recall_max_count', '3') ?? 3), // App-wide clock for due dates and automatisms (DateHelper reads it). 'app_timezone' => (string) \App\Support\ConfigStore::get('app.timezone', 'Europe/Rome'), ]; @@ -1322,6 +1326,13 @@ public function updateLoansSettings(Request $request, Response $response, mysqli $autoApprove = isset($data['auto_approve_requests']) && is_scalar($data['auto_approve_requests']) && (string) $data['auto_approve_requests'] === '1'; + // #360: automatic recall schedule. The same clamps are re-applied at + // read time by NotificationService::sendLoanRecalls(). + $recallEnabled = isset($data['recall_auto_enabled']) + && is_scalar($data['recall_auto_enabled']) + && (string) $data['recall_auto_enabled'] === '1'; + $recallInterval = min(365, max(1, (int) ($data['recall_interval_days'] ?? 7))); // 1 … 365 days + $recallMaxCount = min(50, max(1, (int) ($data['recall_max_count'] ?? 3))); // 1 … 50 recalls $repository->set('loans', 'loan_duration_days', (string) $loanDurationDays); $repository->set('loans', 'pickup_expiry_days', (string) $pickupExpiryDays); @@ -1329,6 +1340,9 @@ public function updateLoansSettings(Request $request, Response $response, mysqli $repository->set('loans', 'max_active_loans_per_user', (string) $maxActiveLoans); $repository->set('loans', 'max_loan_duration_days', (string) $maxLoanDuration); $repository->set('loans', 'auto_approve_requests', $autoApprove ? '1' : '0'); + $repository->set('loans', 'recall_auto_enabled', $recallEnabled ? '1' : '0'); + $repository->set('loans', 'recall_interval_days', (string) $recallInterval); + $repository->set('loans', 'recall_max_count', (string) $recallMaxCount); // App timezone: DateHelper computes the loan clock ("today"/"now") from // this. Validate against the canonical identifier list — an invalid or diff --git a/app/Routes/web.php b/app/Routes/web.php index 5a8a9395b..7007fc792 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -1897,6 +1897,30 @@ return $controller->downloadPdf($request, $response, $db, (int) $args['id']); })->add(new AdminAuthMiddleware())->add(new CsrfMiddleware()); + // #360: invia la ricevuta PDF del prestito via email all'utente + $app->post('/admin/loans/{id:\d+}/email-pdf', function ($request, $response, $args) use ($app) { + $controller = new PrestitiController(); + $db = $app->getContainer()->get('db'); + return $controller->emailPdf($request, $response, $db, (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + // #360: sollecito manuale per un singolo prestito scaduto + $app->post('/admin/loans/{id:\d+}/recall', function ($request, $response, $args) use ($app) { + $controller = new PrestitiController(); + $db = $app->getContainer()->get('db'); + return $controller->sendRecall($request, $response, $db, (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + // #360: sollecito in blocco per i prestiti selezionati nella lista + $app->post('/admin/loans/bulk-recall', function ($request, $response) use ($app) { + if (\App\Support\ConfigStore::isCatalogueMode()) { + return $response->withHeader('Location', '/admin/dashboard')->withStatus(302); + } + $controller = new PrestitiController(); + $db = $app->getContainer()->get('db'); + return $controller->bulkRecall($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // API loans per DataTables $app->get('/api/prestiti', function ($request, $response) use ($app) { $controller = new \App\Controllers\PrestitiApiController(); diff --git a/app/Support/EmailService.php b/app/Support/EmailService.php index c0dc81dab..4f3ff75ea 100644 --- a/app/Support/EmailService.php +++ b/app/Support/EmailService.php @@ -45,6 +45,7 @@ class EmailService { 'loan_days' => 'giorni_prestito', 'loan_id' => 'prestito_id', 'stars' => 'stelle', + 'recall_number' => 'numero_sollecito', // Review fields 'review_date' => 'data_recensione', @@ -158,8 +159,9 @@ private function getEmailSettings(): array { * @param string $templateName Template name * @param array $variables Variables to replace in template * @param string|null $locale Locale (it_IT, en_US). If null, uses current user's locale + * @param array $attachments Each entry: ['content' => binary string, 'filename' => string, 'type' => MIME type] */ - public function sendTemplate(string $to, string $templateName, array $variables = [], ?string $locale = null): bool { + public function sendTemplate(string $to, string $templateName, array $variables = [], ?string $locale = null, array $attachments = []): bool { try { $template = $this->getEmailTemplate($templateName, $locale); if (!$template) { @@ -172,7 +174,7 @@ public function sendTemplate(string $to, string $templateName, array $variables $subject = $this->replaceVariables($template['subject'], $variables, false); $body = $this->replaceVariables($template['body'], $variables); - return $this->sendEmail($to, $subject, $body, '', $locale); + return $this->sendEmail($to, $subject, $body, '', $locale, $attachments); } catch (\Throwable $e) { error_log("Failed to send template email '{$templateName}' to {$to}: " . $e->getMessage()); @@ -182,18 +184,40 @@ public function sendTemplate(string $to, string $templateName, array $variables /** * Send plain email + * + * @param array $attachments Each entry: ['content' => binary string, 'filename' => string, 'type' => MIME type]. + * In-memory attachments (addStringAttachment): no temp files on disk. */ - public function sendEmail(string $to, string $subject, string $body, string $toName = '', ?string $locale = null): bool { + public function sendEmail(string $to, string $subject, string $body, string $toName = '', ?string $locale = null, array $attachments = []): bool { try { $this->mailer->clearAddresses(); + // The PHPMailer instance is reused across sends (e.g. sendToAdmins + // loops): clear attachments from any previous message so they don't + // leak into this one. + $this->mailer->clearAttachments(); $this->mailer->addAddress($to, $toName); + foreach ($attachments as $attachment) { + $content = (string) ($attachment['content'] ?? ''); + if ($content === '') { + continue; + } + $this->mailer->addStringAttachment( + $content, + (string) ($attachment['filename'] ?? 'allegato'), + PHPMailer::ENCODING_BASE64, + (string) ($attachment['type'] ?? 'application/octet-stream') + ); + } $this->mailer->Subject = $subject; $this->mailer->Body = $this->wrapInBaseTemplate($body, $subject, $locale); $this->mailer->AltBody = EmailLayout::plainText($body); - return $this->mailer->send(); + $sent = $this->mailer->send(); + $this->mailer->clearAttachments(); + return $sent; } catch (\Throwable $e) { + $this->mailer->clearAttachments(); error_log("Failed to send email to {$to}: " . $e->getMessage()); return false; } diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php index d742e84cb..6f4592138 100644 --- a/app/Support/NotificationService.php +++ b/app/Support/NotificationService.php @@ -576,6 +576,237 @@ public function sendOverdueLoanNotifications(): int { return $sentCount; } + /** + * #360: automatic recalls (solleciti) for overdue loans. + * + * Unlike sendOverdueLoanNotifications() — one-shot via the boolean + * overdue_notification_sent — recalls repeat: recall N is due once the loan + * is at least N * interval days overdue (interval and max count come from + * the loans settings), so a loan that stays out keeps being chased up to + * loans.recall_max_count times. Uses the same atomic claim-then-send + * pattern as the other senders; the DATE(last_recall_at) guard caps sends + * at one per loan per day even if the schedule would allow more. + */ + public function sendLoanRecalls(): int { + $sentCount = 0; + + try { + if ((string) ConfigStore::get('loans.recall_auto_enabled', '0') !== '1') { + return 0; + } + // Same clamps as SettingsController::updateLoansSettings — the + // stored value is trusted but a hand-edited row must not produce a + // zero/negative interval (division-like schedule) or a runaway cap. + $intervalDays = min(365, max(1, (int) ConfigStore::get('loans.recall_interval_days', 7))); + $maxRecalls = min(50, max(1, (int) ConfigStore::get('loans.recall_max_count', 3))); + + // "Oggi" nel timezone applicativo come parametro bound (M9). + $today = DateHelper::today(); + + $stmt = $this->db->prepare(" + SELECT p.id, p.data_scadenza, p.recall_count, p.last_recall_at, + l.titolo as libro_titolo, + CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email as utente_email, + DATEDIFF(?, p.data_scadenza) as giorni_ritardo + FROM prestiti p + JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL + JOIN utenti u ON p.utente_id = u.id + WHERE p.stato IN ('in_corso', 'in_ritardo') + AND p.attivo = 1 + AND p.data_scadenza < ? + AND p.overdue_notification_sent = 1 + AND p.recall_count < ? + AND DATEDIFF(?, p.data_scadenza) >= ? * (p.recall_count + 1) + AND (p.last_recall_at IS NULL OR DATE(p.last_recall_at) < ?) + "); + $stmt->bind_param('ssisis', $today, $today, $maxRecalls, $today, $intervalDays, $today); + $stmt->execute(); + $result = $stmt->get_result(); + + $loans = []; + while ($loan = $result->fetch_assoc()) { + $loans[] = $loan; + } + $stmt->close(); + + foreach ($loans as $loan) { + $sentCount += $this->claimAndSendRecall($loan, $today) ? 1 : 0; + } + + } catch (\Throwable $e) { + SecureLogger::error("Failed to send loan recalls: " . $e->getMessage()); + } + + return $sentCount; + } + + /** + * #360: manual recall for a single loan, triggered by staff from the loan + * detail page or the loans-list bulk action. Skips the automatic schedule + * (interval / max count): an explicit staff action always sends — but the + * loan must genuinely be overdue and the user must have an email address. + * + * @return array{success: bool, message: string} + */ + public function sendManualRecall(int $loanId): array { + try { + $this->addNotificationColumns(); + + $today = DateHelper::today(); + $stmt = $this->db->prepare(" + SELECT p.id, p.data_scadenza, p.recall_count, p.last_recall_at, + p.stato, p.attivo, + l.titolo as libro_titolo, + CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email as utente_email, + DATEDIFF(?, p.data_scadenza) as giorni_ritardo + FROM prestiti p + JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL + JOIN utenti u ON p.utente_id = u.id + WHERE p.id = ? + "); + $stmt->bind_param('si', $today, $loanId); + $stmt->execute(); + $loan = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + + if (!$loan) { + return ['success' => false, 'message' => __('Prestito non trovato')]; + } + if ((int) $loan['attivo'] !== 1 || !in_array((string) $loan['stato'], ['in_corso', 'in_ritardo'], true)) { + return ['success' => false, 'message' => __('Il sollecito è disponibile solo per prestiti attivi con libro in mano all\'utente.')]; + } + if ((int) $loan['giorni_ritardo'] < 1) { + return ['success' => false, 'message' => __('Il prestito non è scaduto: nessun sollecito da inviare.')]; + } + if (trim((string) $loan['utente_email']) === '') { + return ['success' => false, 'message' => __('L\'utente non ha un indirizzo email.')]; + } + + if ($this->claimAndSendRecall($loan, $today)) { + return ['success' => true, 'message' => __('Sollecito inviato con successo.')]; + } + return ['success' => false, 'message' => __('Invio del sollecito non riuscito. Controlla la configurazione email e riprova.')]; + + } catch (\Throwable $e) { + SecureLogger::error("Failed to send manual recall for loan {$loanId}: " . $e->getMessage()); + return ['success' => false, 'message' => __('Invio del sollecito non riuscito. Controlla la configurazione email e riprova.')]; + } + } + + /** + * Shared claim-then-send for one recall (automatic and manual paths). + * Expects a row carrying id, data_scadenza, recall_count, last_recall_at, + * libro_titolo, utente_nome, utente_email, giorni_ritardo. Returns true iff + * the recall email actually went out; on failure the claim is reverted so a + * later run can retry. + */ + private function claimAndSendRecall(array $loan, string $today): bool { + // ATOMIC: bump the counter BEFORE sending. Re-assert data_scadenza and + // recall_count so a concurrent renew()/recall claims at most once + // (same #252/M3 rationale as the overdue sender). + $now = DateHelper::now(); + $expectedCount = (int) $loan['recall_count']; + $updateStmt = $this->db->prepare("UPDATE prestiti SET recall_count = recall_count + 1, last_recall_at = ? WHERE id = ? AND data_scadenza = ? AND recall_count = ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')"); + $updateStmt->bind_param('sisi', $now, $loan['id'], $loan['data_scadenza'], $expectedCount); + $updateStmt->execute(); + $claimed = $updateStmt->affected_rows === 1; + $updateStmt->close(); + + if (!$claimed) { + return false; + } + + $recallNumber = $expectedCount + 1; + $variables = [ + 'utente_nome' => $loan['utente_nome'], + 'libro_titolo' => $loan['libro_titolo'], + 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza']), + 'giorni_ritardo' => $loan['giorni_ritardo'], + 'numero_sollecito' => $recallNumber, + ]; + + $emailSent = $this->sendWithRetry($loan['utente_email'], 'loan_recall_notification', $variables); + + if ($emailSent) { + $this->createNotification( + 'general', + __('Sollecito inviato'), + sprintf(__('Sollecito n. %d inviato a %s per "%s"'), $recallNumber, $loan['utente_nome'], $loan['libro_titolo']), + '/admin/loans', + (int) $loan['id'] + ); + return true; + } + + // Email failed after retries: restore counter and timestamp so the next + // run (or a retried manual send) claims the same recall again. + $previousRecallAt = $loan['last_recall_at'] !== null ? (string) $loan['last_recall_at'] : null; + $revertStmt = $this->db->prepare("UPDATE prestiti SET recall_count = recall_count - 1, last_recall_at = ? WHERE id = ? AND recall_count = ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')"); + $revertStmt->bind_param('sii', $previousRecallAt, $loan['id'], $recallNumber); + $revertStmt->execute(); + $revertStmt->close(); + SecureLogger::warning("Failed to send recall for loan {$loan['id']} after retries, claim reverted"); + return false; + } + + /** + * #360: email the loan receipt PDF (the same document downloadPdf serves) + * to the loan's user, attached to the loan_receipt_email template. + * + * @return array{success: bool, message: string} + */ + public function sendLoanReceiptEmail(int $loanId): array { + try { + $repo = new \App\Models\LoanRepository($this->db); + $loan = $repo->getById($loanId); + if (!$loan) { + return ['success' => false, 'message' => __('Prestito non trovato')]; + } + $email = trim((string) ($loan['utente_email'] ?? '')); + if ($email === '') { + return ['success' => false, 'message' => __('L\'utente non ha un indirizzo email.')]; + } + + $pdfContent = (new LoanPdfGenerator($this->db))->generate($loanId); + + $variables = [ + 'prestito_id' => $loanId, + 'utente_nome' => (string) ($loan['utente'] ?? ''), + 'libro_titolo' => (string) ($loan['libro'] ?? ''), + 'data_prestito' => $this->formatEmailDate((string) ($loan['data_prestito'] ?? '')), + 'data_scadenza' => $this->formatEmailDate((string) ($loan['data_scadenza'] ?? '')), + ]; + $attachment = [ + 'content' => $pdfContent, + 'filename' => 'prestito_' . $loanId . '_' . date('Ymd') . '.pdf', + 'type' => 'application/pdf', + ]; + + // Same circuit breaker as sendWithRetry: don't spin the SMTP retry + // cycle when the server is plainly unreachable. + if (!\App\Support\Mailer::isSmtpReachable()) { + return ['success' => false, 'message' => __('Invio email non riuscito. Controlla la configurazione email e riprova.')]; + } + + $sent = $this->emailService->sendTemplate( + $email, + 'loan_receipt_email', + $variables, + \App\Support\I18n::getInstallationLocale(), + [$attachment] + ); + + if ($sent) { + return ['success' => true, 'message' => __('Ricevuta inviata via email con successo.')]; + } + return ['success' => false, 'message' => __('Invio email non riuscito. Controlla la configurazione email e riprova.')]; + + } catch (\Throwable $e) { + SecureLogger::error("Failed to email loan receipt for loan {$loanId}: " . $e->getMessage()); + return ['success' => false, 'message' => __('Invio email non riuscito. Controlla la configurazione email e riprova.')]; + } + } + public function notifyAdminsOverdue(int $loanId): void { try { @@ -710,6 +941,7 @@ public function runAutomaticNotifications(): array { 'timestamp' => gmdate('Y-m-d H:i:s'), 'expiration_warnings' => 0, 'overdue_notifications' => 0, + 'loan_recalls' => 0, 'wishlist_notifications' => 0, 'errors' => [] ]; @@ -721,6 +953,9 @@ public function runAutomaticNotifications(): array { $results['expiration_warnings'] = $this->sendLoanExpirationWarnings(); $results['overdue_notifications'] = $this->sendOverdueLoanNotifications(); + // #360: repeated recalls come after the first overdue notice — the + // recall query requires overdue_notification_sent = 1. + $results['loan_recalls'] = $this->sendLoanRecalls(); $results['wishlist_notifications'] = $this->checkAndNotifyWishlistAvailability(); } catch (\Throwable $e) { @@ -862,6 +1097,20 @@ private function addNotificationColumns(): void { $this->db->query("ALTER TABLE prestiti ADD COLUMN overdue_notification_sent BOOLEAN DEFAULT 0"); } + // #360: recall (sollecito) tracking — how many recalls went out and + // when the last one did, so automatic recalls can repeat at the + // configured interval instead of being one-shot like + // overdue_notification_sent. + $result = $this->db->query("SHOW COLUMNS FROM prestiti LIKE 'recall_count'"); + if ($result->num_rows === 0) { + $this->db->query("ALTER TABLE prestiti ADD COLUMN recall_count INT NOT NULL DEFAULT 0"); + } + + $result = $this->db->query("SHOW COLUMNS FROM prestiti LIKE 'last_recall_at'"); + if ($result->num_rows === 0) { + $this->db->query("ALTER TABLE prestiti ADD COLUMN last_recall_at DATETIME NULL DEFAULT NULL"); + } + } catch (\Throwable $e) { SecureLogger::error("Failed to add notification columns: " . $e->getMessage()); } diff --git a/app/Support/SettingsMailTemplates.php b/app/Support/SettingsMailTemplates.php index 793188d0b..2c8c42902 100644 --- a/app/Support/SettingsMailTemplates.php +++ b/app/Support/SettingsMailTemplates.php @@ -168,6 +168,31 @@ public static function all(?string $locale = null): array

Il mancato rientro del libro potrebbe comportare la sospensione del tuo account e penali.

Ti chiediamo di restituire il libro il prima possibile.

+HTML, + ], + // #360: sollecito (recall) — repeatable reminder for an overdue loan, + // sent automatically at a configurable interval and/or manually by + // staff from the loan detail page or the loans list bulk action. + 'loan_recall_notification' => [ + 'label' => __('Sollecito restituzione'), + 'description' => __("Sollecito inviato all'utente per la restituzione di un prestito scaduto (automatico o manuale)."), + 'subject' => '📢 Sollecito n. {{numero_sollecito}} - Restituzione richiesta', + 'placeholders' => ['utente_nome', 'libro_titolo', 'data_scadenza', 'giorni_ritardo', 'numero_sollecito'], + 'body' => <<<'HTML' +

Sollecito di restituzione

+

Ciao {{utente_nome}},

+

Nonostante i precedenti avvisi, il seguente prestito risulta ancora scaduto e il libro non è stato restituito:

+ +
+

❗️ Azione richiesta

+

Ti chiediamo di restituire il libro al più presto o di contattare la biblioteca. Il mancato rientro potrebbe comportare la sospensione del tuo account e penali.

+
+

Se hai già restituito il libro, ignora questo messaggio.

HTML, ], 'loan_overdue_admin' => [ @@ -185,6 +210,26 @@ public static function all(?string $locale = null): array
  • Data scadenza: {{data_scadenza}}
  • Intervieni per contattare l'utente e sollecitare la restituzione.

    +HTML, + ], + // #360: cover message for the loan receipt PDF sent by email from the + // loan detail page (the PDF itself is attached by the sender). + 'loan_receipt_email' => [ + 'label' => __('Ricevuta prestito via email'), + 'description' => __("Email inviata all'utente con la ricevuta PDF del prestito in allegato."), + 'subject' => '📄 Ricevuta del prestito #{{prestito_id}}', + 'placeholders' => ['utente_nome', 'libro_titolo', 'data_prestito', 'data_scadenza', 'prestito_id'], + 'body' => <<<'HTML' +

    Ricevuta del prestito

    +

    Ciao {{utente_nome}},

    +

    In allegato trovi la ricevuta in PDF del tuo prestito:

    + +

    Conserva questa ricevuta come promemoria della scadenza.

    +

    Buona lettura!

    HTML, ], 'loan_approved' => [ @@ -612,6 +657,7 @@ public static function placeholderDescriptions(): array 'motivo' => __('Motivo'), 'motivo_rifiuto' => __('Motivo del rifiuto'), 'nome' => __('Nome dell\'utente'), + 'numero_sollecito' => __('Numero progressivo del sollecito inviato'), 'pickup_deadline' => __('Scadenza per il ritiro del libro'), 'pickup_instructions' => __('Istruzioni per il ritiro del libro'), 'prestito_id' => __('Identificativo del prestito'), diff --git a/app/Support/mail_templates/da_DK.php b/app/Support/mail_templates/da_DK.php index 124ee1e0f..43b3b8fb4 100644 --- a/app/Support/mail_templates/da_DK.php +++ b/app/Support/mail_templates/da_DK.php @@ -151,6 +151,36 @@

    Kontakt biblioteket omgående for at løse situationen.

    ', ], + 'loan_recall_notification' => [ + 'subject' => '📢 Rykker nr. {{numero_sollecito}} - Aflevering påkrævet', + 'body' => '

    Rykker for aflevering

    +

    Hej {{utente_nome}},

    +

    Trods tidligere påmindelser er følgende lån stadig udløbet, og bogen er ikke blevet afleveret:

    + +
    +

    ❗️ Handling påkrævet

    +

    Aflever venligst bogen hurtigst muligt, eller kontakt biblioteket. Manglende aflevering kan medføre suspendering af din konto og gebyrer.

    +
    +

    Hvis du allerede har afleveret bogen, kan du se bort fra denne besked.

    ', + ], + 'loan_receipt_email' => [ + 'subject' => '📄 Lånekvittering #{{prestito_id}}', + 'body' => '

    Lånekvittering

    +

    Hej {{utente_nome}},

    +

    Vedhæftet finder du PDF-kvitteringen for dit lån:

    + +

    Gem denne kvittering som en påmindelse om afleveringsdatoen.

    +

    God læselyst!

    ', + ], 'loan_pickup_cancelled' => [ 'subject' => '❌ Afhentning annulleret', 'body' => '

    Afhentning annulleret

    diff --git a/app/Support/mail_templates/de_DE.php b/app/Support/mail_templates/de_DE.php index e5998cf50..86f53bf60 100644 --- a/app/Support/mail_templates/de_DE.php +++ b/app/Support/mail_templates/de_DE.php @@ -141,6 +141,36 @@

    Wird das Buch nicht zurückgegeben, kann dies zur Sperrung deines Kontos und zu Gebühren führen.

    Bitte gib das Buch so schnell wie möglich zurück.

    ', + ], + 'loan_recall_notification' => [ + 'subject' => '📢 Mahnung Nr. {{numero_sollecito}} - Rückgabe erforderlich', + 'body' => '

    Rückgabemahnung

    +

    Hallo {{utente_nome}},

    +

    Trotz vorheriger Hinweise ist die folgende Ausleihe weiterhin überfällig und das Buch wurde nicht zurückgegeben:

    + +
    +

    ❗️ Handlung erforderlich

    +

    Bitte gib das Buch so schnell wie möglich zurück oder kontaktiere die Bibliothek. Wird das Buch nicht zurückgegeben, kann dies zur Sperrung deines Kontos und zu Gebühren führen.

    +
    +

    Falls du das Buch bereits zurückgegeben hast, betrachte diese Nachricht als gegenstandslos.

    ', + ], + 'loan_receipt_email' => [ + 'subject' => '📄 Ausleihbeleg #{{prestito_id}}', + 'body' => '

    Ausleihbeleg

    +

    Hallo {{utente_nome}},

    +

    Im Anhang findest du den PDF-Beleg deiner Ausleihe:

    + +

    Bewahre diesen Beleg als Erinnerung an das Fälligkeitsdatum auf.

    +

    Viel Spaß beim Lesen!

    ', ], 'loan_pickup_cancelled' => [ 'subject' => '❌ Abholung storniert', diff --git a/app/Support/mail_templates/en_US.php b/app/Support/mail_templates/en_US.php index 076c68490..771c6e6b7 100644 --- a/app/Support/mail_templates/en_US.php +++ b/app/Support/mail_templates/en_US.php @@ -141,6 +141,36 @@

    Failure to return the book may result in the suspension of your account and penalties.

    Please return the book as soon as possible.

    ', + ], + 'loan_recall_notification' => [ + 'subject' => '📢 Reminder no. {{numero_sollecito}} - Return required', + 'body' => '

    Return reminder

    +

    Hi {{utente_nome}},

    +

    Despite previous notices, the following loan is still overdue and the book has not been returned:

    + +
    +

    ❗️ Action required

    +

    Please return the book as soon as possible or contact the library. Failure to return it may result in the suspension of your account and penalties.

    +
    +

    If you have already returned the book, please disregard this message.

    ', + ], + 'loan_receipt_email' => [ + 'subject' => '📄 Loan receipt #{{prestito_id}}', + 'body' => '

    Loan receipt

    +

    Hi {{utente_nome}},

    +

    Please find attached the PDF receipt for your loan:

    + +

    Keep this receipt as a reminder of the due date.

    +

    Happy reading!

    ', ], 'loan_pickup_cancelled' => [ 'subject' => '❌ Pickup Cancelled', diff --git a/app/Support/mail_templates/fr_FR.php b/app/Support/mail_templates/fr_FR.php index 998900fa1..5f05b613b 100644 --- a/app/Support/mail_templates/fr_FR.php +++ b/app/Support/mail_templates/fr_FR.php @@ -141,6 +141,36 @@

    Le non-retour du livre peut entraîner la suspension de votre compte ainsi que des pénalités.

    Nous vous prions de bien vouloir restituer le livre dans les plus brefs délais.

    ', + ], + 'loan_recall_notification' => [ + 'subject' => '📢 Rappel n° {{numero_sollecito}} - Retour requis', + 'body' => '

    Rappel de restitution

    +

    Bonjour {{utente_nome}},

    +

    Malgré les avis précédents, le prêt suivant est toujours en retard et le livre n\'a pas été restitué :

    + +
    +

    ❗️ Action requise

    +

    Merci de restituer le livre au plus vite ou de contacter la bibliothèque. Le non-retour du livre peut entraîner la suspension de votre compte ainsi que des pénalités.

    +
    +

    Si vous avez déjà restitué le livre, veuillez ignorer ce message.

    ', + ], + 'loan_receipt_email' => [ + 'subject' => '📄 Reçu du prêt #{{prestito_id}}', + 'body' => '

    Reçu du prêt

    +

    Bonjour {{utente_nome}},

    +

    Vous trouverez en pièce jointe le reçu PDF de votre prêt :

    + +

    Conservez ce reçu comme rappel de la date d\'échéance.

    +

    Bonne lecture !

    ', ], 'loan_pickup_cancelled' => [ 'subject' => '❌ Retrait annulé', diff --git a/app/Views/prestiti/dettagli_prestito.php b/app/Views/prestiti/dettagli_prestito.php index b15ccba21..90bc9e9f9 100644 --- a/app/Views/prestiti/dettagli_prestito.php +++ b/app/Views/prestiti/dettagli_prestito.php @@ -151,6 +151,31 @@ class="px-4 py-2 bg-red-600 text-white hover:bg-red-500 rounded-lg transition-co + + + + + + + @@ -175,7 +200,16 @@ class="px-4 py-2 bg-red-600 text-white hover:bg-red-500 rounded-lg transition-co 'Rifiuta', 'Rifiutato', 'Il prestito è stato rifiutato.', - 'Errore durante il rifiuto' + 'Errore durante il rifiuto', + 'Inviare un sollecito per questo prestito?', + 'L\'utente riceverà un\'email di sollecito per la restituzione.', + 'Sì, invia', + 'Sollecito inviato', + 'Invio del sollecito non riuscito.', + 'Inviare la ricevuta del prestito via email?', + 'L\'utente riceverà un\'email con la ricevuta PDF in allegato.', + 'Ricevuta inviata', + 'Invio della ricevuta non riuscito.' ]; $jsTranslations = []; foreach ($jsTranslationKeys as $key) { @@ -334,5 +368,90 @@ class="px-4 py-2 bg-red-600 text-white hover:bg-red-500 rounded-lg transition-co } }); } + + // #360: shared POST-and-report helper for the recall / email-receipt buttons. + async function postLoanEmailAction(url, confirmTitle, confirmText, successTitle, failureText, button) { + const result = await Swal.fire({ + title: confirmTitle, + text: confirmText, + icon: 'question', + showCancelButton: true, + confirmButtonText: __('Sì, invia'), + cancelButtonText: __('Annulla'), + confirmButtonColor: '#111827', + cancelButtonColor: '#6b7280' + }); + if (!result.isConfirmed) { + return; + } + button.disabled = true; + try { + const response = await fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf + }, + body: JSON.stringify({}) + }); + const data = await response.json(); + if (data.success) { + Swal.fire({ + title: successTitle, + text: data.message || '', + icon: 'success', + confirmButtonText: __('OK'), + confirmButtonColor: '#111827' + }); + } else { + Swal.fire({ + title: __('Errore'), + text: data.message || failureText, + icon: 'error', + confirmButtonText: __('OK'), + confirmButtonColor: '#111827' + }); + } + } catch (error) { + Swal.fire({ + title: __('Errore'), + text: __('Errore nella comunicazione con il server'), + icon: 'error', + confirmButtonText: __('OK'), + confirmButtonColor: '#111827' + }); + } finally { + button.disabled = false; + } + } + + const recallBtn = document.getElementById('send-recall-btn'); + if (recallBtn) { + recallBtn.addEventListener('click', function() { + postLoanEmailAction( + window.BASE_PATH + '/admin/loans/' + parseInt(this.dataset.loanId, 10) + '/recall', + __('Inviare un sollecito per questo prestito?'), + __('L\'utente riceverà un\'email di sollecito per la restituzione.'), + __('Sollecito inviato'), + __('Invio del sollecito non riuscito.'), + this + ); + }); + } + + const receiptBtn = document.getElementById('email-receipt-btn'); + if (receiptBtn) { + receiptBtn.addEventListener('click', function() { + postLoanEmailAction( + window.BASE_PATH + '/admin/loans/' + parseInt(this.dataset.loanId, 10) + '/email-pdf', + __('Inviare la ricevuta del prestito via email?'), + __('L\'utente riceverà un\'email con la ricevuta PDF in allegato.'), + __('Ricevuta inviata'), + __('Invio della ricevuta non riuscito.'), + this + ); + }); + } })(); diff --git a/app/Views/prestiti/index.php b/app/Views/prestiti/index.php index 1bc0c80a3..763704e8b 100644 --- a/app/Views/prestiti/index.php +++ b/app/Views/prestiti/index.php @@ -425,6 +425,10 @@ class="w-20 px-2 py-1.5 border border-gray-300 rounded-lg text-sm text-center"> class="inline-flex items-center px-4 py-2 bg-gray-800 text-white hover:bg-gray-700 rounded-lg transition-colors text-sm"> +