diff --git a/CHANGELOG.md b/CHANGELOG.md index 577cc7ac2..f55428f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here. +## [Unreleased] + +## [0.7.62-rc.1] - 2026-08-19 + +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-rc.1.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). +- **Emails in the recipient's language**: user-facing notification emails + (loan warnings/overdue/recalls, receipt, approvals, pickups, returns, + reservations, wishlist, registration and account emails — and per-admin for + admin alerts) now render in the recipient's preferred language + (`utenti.locale`, the same value that drives their UI language), including + date formats and translated labels, falling back to the installation locale + when the user has none. Password-reset mail already followed the + requester's session language. +- **Language choice at registration**: on multi-language installs the + registration form now offers a "Lingua preferita" select (defaulting to the + language the visitor is browsing in), validated server-side against the + shipped locales. The profile page and the admin user forms already offered + the same choice; together they cover registration, self-service and admin. + ## [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..3b1fd0d60 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -1025,12 +1025,24 @@ public function update(Request $request, Response $response, mysqli $db, int $id "UPDATE prestiti SET stato = CASE WHEN data_scadenza < ? THEN 'in_ritardo' ELSE 'in_corso' END, warning_sent = CASE WHEN data_scadenza < ? THEN warning_sent ELSE 0 END, - overdue_notification_sent = CASE WHEN data_scadenza < ? THEN overdue_notification_sent ELSE 0 END + overdue_notification_sent = CASE WHEN data_scadenza < ? THEN overdue_notification_sent ELSE 0 END, + recall_count = 0, + last_recall_at = NULL WHERE id = ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')" ); $recalcStato->bind_param('sssi', $today, $today, $today, $id); $recalcStato->execute(); $recalcStato->close(); + } elseif ($newUserId !== (int) $locked['utente_id']) { + // A recall belongs to the recipient who received it. Reassigning + // the loan must not carry that recipient's count/cooldown over to + // the new user, even when the due date itself is unchanged. + $resetRecall = $db->prepare( + 'UPDATE prestiti SET recall_count = 0, last_recall_at = NULL WHERE id = ?' + ); + $resetRecall->bind_param('i', $id); + $resetRecall->execute(); + $resetRecall->close(); } // Ricalcola la disponibilità (M6c): spostare le date di un 'prenotato' @@ -1622,7 +1634,9 @@ public function bulkExtend(Request $request, Response $response, mysqli $db): Re SET data_scadenza = ?, stato = CASE WHEN ? < ? THEN 'in_ritardo' ELSE 'in_corso' END, warning_sent = CASE WHEN ? < ? THEN warning_sent ELSE 0 END, - overdue_notification_sent = CASE WHEN ? < ? THEN overdue_notification_sent ELSE 0 END + overdue_notification_sent = CASE WHEN ? < ? THEN overdue_notification_sent ELSE 0 END, + recall_count = 0, + last_recall_at = NULL WHERE id = ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')" ); @@ -1941,7 +1955,8 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) $updateStmt = $db->prepare(" UPDATE prestiti SET data_scadenza = ?, renewals = ?, pickup_deadline = NULL, - warning_sent = 0, overdue_notification_sent = 0 + warning_sent = 0, overdue_notification_sent = 0, + recall_count = 0, last_recall_at = NULL WHERE id = ? "); $updateStmt->bind_param("sii", $newDueDate, $newRenewalCount, $id); @@ -2159,6 +2174,121 @@ 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; + } + if ($blocked = $this->catalogueModeJsonGuard($response)) { + return $blocked; + } + // 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. + */ + /** + * Upper bound on one synchronous bulk-recall batch. Each loan is a real + * SMTP send inside this HTTP request, so the cap must stay small enough + * not to trip max_execution_time (same rationale as the 20-item cap on + * /admin/books/bulk-enrich/start). Larger overdue backlogs are what the + * automatic recall scheduler is for. + */ + private const BULK_RECALL_MAX_LOANS = 50; + + /** + * A bulk action is synchronous, but it must not monopolize the PHP worker. + * One SMTP attempt is still bounded separately by EmailService's timeout. + */ + private const BULK_RECALL_TIME_BUDGET_SECONDS = 15.0; + + 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; + $startedAt = hrtime(true); + $total = count($ids); + foreach ($ids as $index => $loanId) { + $elapsedSeconds = (hrtime(true) - $startedAt) / 1_000_000_000; + if ($elapsedSeconds >= self::BULK_RECALL_TIME_BUDGET_SECONDS) { + $skipped += $total - $index; + break; + } + + // The interactive single-loan endpoint keeps its retry policy. In a + // bulk HTTP request use one bounded attempt per recipient: repeating + // the same SMTP failure up to 50 times would exceed the request SLA. + $result = $service->sendManualRecall($loanId, 1, 0); + 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; + } + if ($blocked = $this->catalogueModeJsonGuard($response)) { + return $blocked; + } + // 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'] ?? ''; @@ -2167,4 +2297,23 @@ private function guardStaffAccess(Response $response): ?Response } return null; } + + /** + * #360: refuse the loan email/recall JSON endpoints in catalogue mode, the + * same intent bulkRecall's route enforces with a redirect. Loans are + * disabled in catalogue mode, so a lingering row must not still trigger a + * patron email through the single-item AJAX endpoints. Returns a JSON body + * (these are fetch() endpoints) instead of a redirect. + */ + private function catalogueModeJsonGuard(Response $response): ?Response + { + if (!\App\Support\ConfigStore::isCatalogueMode()) { + return null; + } + $response->getBody()->write((string) json_encode([ + 'success' => false, + 'message' => __('Funzione non disponibile in modalità catalogo.'), + ])); + return $response->withHeader('Content-Type', 'application/json')->withStatus(403); + } } diff --git a/app/Controllers/RegistrationController.php b/app/Controllers/RegistrationController.php index 754f17dc4..f8835ea8c 100644 --- a/app/Controllers/RegistrationController.php +++ b/app/Controllers/RegistrationController.php @@ -146,12 +146,21 @@ public function register(Request $request, Response $response, mysqli $db): Resp // Default stato: sospeso (richiede approvazione admin). Email da verificare $stato = 'sospeso'; $ruolo = 'standard'; - // The application language is selected for the whole installation. - // Persist it explicitly instead of relying on the historical it_IT - // schema default, which is wrong on installations using another locale. - $locale = \App\Support\I18n::normalizeLocaleCode( - \App\Support\I18n::getInstallationLocale() - ); + // #360: the registrant picks their language on the form (the select is + // rendered only on multi-language installs). Validate against the + // locales this installation actually ships; anything missing or + // invalid falls back to the installation locale — the historical + // behaviour. The stored value drives the user's UI language and the + // language of every email the app sends them. + $locale = ''; + if (isset($data['locale']) && is_scalar($data['locale'])) { + $locale = \App\Support\I18n::normalizeLocaleCode(trim((string) $data['locale'])); + } + if ($locale === '' || !isset(\App\Support\I18n::getAvailableLocales()[$locale])) { + $locale = \App\Support\I18n::normalizeLocaleCode( + \App\Support\I18n::getInstallationLocale() + ); + } if (!\App\Support\I18n::isValidLocaleCode($locale)) { $locale = 'it_IT'; } diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 4cc0d72e1..41b38be4b 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -22,10 +22,13 @@ public function index(Request $request, Response $response, mysqli $db): Respons { $repository = new SettingsRepository($db); $repository->ensureTables(); - // Semina i template mancanti con il locale di installazione (M8): - // l'invio li risolve per (name, locale_installazione), quindi seminare - // sempre it_IT li renderebbe invisibili su installazioni non italiane. - $repository->ensureEmailTemplates($this->templateDefaults(), \App\Support\I18n::getInstallationLocale()); + $queryParams = $request->getQueryParams(); + $templateLocales = \App\Support\I18n::getAvailableLocales(); + $templateLocale = $this->resolveTemplateEditorLocale($queryParams['template_locale'] ?? null); + // Seed the locale currently edited. Other recipient locales continue to + // use their shipped translation until an administrator opens/customizes + // them, at which point they get an independent stored row. + $repository->ensureEmailTemplates($this->templateDefaults($templateLocale), $templateLocale); // #299: repair any stored template whose links were double-prefixed with // the admin base by the old WYSIWYG behaviour. Idempotent, cheap (LIKE // filter), runs before the templates are read for display below. @@ -33,7 +36,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons $appSettings = $this->resolveAppSettings($repository); $emailSettings = $this->resolveEmailSettings($repository); - $templates = $this->resolveEmailTemplates($repository); + $templates = $this->resolveEmailTemplates($repository, $templateLocale); $contactSettings = $this->resolveContactSettings($repository); $privacySettings = $this->resolvePrivacySettings($repository); $labelSettings = $this->resolveLabelSettings($repository); @@ -47,7 +50,6 @@ public function index(Request $request, Response $response, mysqli $db): Respons // re-enable them. $registrationCustomFields = \App\Support\RegistrationFields::definitions($db, false); - $queryParams = $request->getQueryParams(); $activeTab = $queryParams['tab'] ?? 'general'; // Security scan F11 (CWE-522): the settings page is reachable by 'staff' @@ -61,6 +63,8 @@ public function index(Request $request, Response $response, mysqli $db): Respons 'appSettings', 'emailSettings', 'templates', + 'templateLocale', + 'templateLocales', 'contactSettings', 'privacySettings', 'labelSettings', @@ -448,20 +452,26 @@ public function updateEmailTemplate(Request $request, Response $response, mysqli $data = (array) $request->getParsedBody(); // CSRF validated by CsrfMiddleware + $templateLocale = $this->resolveTemplateEditorLocale($data['template_locale'] ?? null); + $localizedDefinition = SettingsMailTemplates::get($template, $templateLocale) ?? $definition; - $subject = trim((string) ($data['subject'] ?? $definition['subject'])); + $subject = trim((string) ($data['subject'] ?? $localizedDefinition['subject'])); if ($subject === '') { - $subject = $definition['subject']; + $subject = $localizedDefinition['subject']; } - $body = \App\Support\EmailLayout::normalizeContent((string) ($data['body'] ?? $definition['body'])); + $body = \App\Support\EmailLayout::normalizeContent((string) ($data['body'] ?? $localizedDefinition['body'])); $repository = new SettingsRepository($db); $repository->ensureTables(); - // Salva sulla riga del locale di installazione: è quella letta dall'invio (M8). - $repository->saveEmailTemplate($template, $subject, $body, $definition['description'] ?? null, true, \App\Support\I18n::getInstallationLocale()); + // Each recipient locale owns an independently editable row. This keeps + // localized sends from bypassing the administrator's customization. + $repository->saveEmailTemplate($template, $subject, $body, $localizedDefinition['description'] ?? null, true, $templateLocale); $_SESSION['success_message'] = 'Template email "' . $definition['label'] . '" aggiornato correttamente.'; - return $this->redirect($response, '/admin/settings?tab=templates&template=' . urlencode($template)); + return $this->redirect( + $response, + '/admin/settings?tab=templates&template_locale=' . rawurlencode($templateLocale) . '&template=' . rawurlencode($template) + ); } private function resolveAppSettings(SettingsRepository $repository): array @@ -530,11 +540,10 @@ private function resolveEmailSettings(SettingsRepository $repository): array return $settings; } - private function resolveEmailTemplates(SettingsRepository $repository): array + private function resolveEmailTemplates(SettingsRepository $repository, string $locale): array { - $definitions = SettingsMailTemplates::all(); - // L'editor mostra le righe del locale di installazione, le stesse usate dall'invio (M8). - $records = $repository->getEmailTemplates(array_keys($definitions), \App\Support\I18n::getInstallationLocale()); + $definitions = SettingsMailTemplates::all($locale); + $records = $repository->getEmailTemplates(array_keys($definitions), $locale); $templates = []; foreach ($definitions as $name => $meta) { @@ -557,10 +566,10 @@ private function resolveEmailTemplates(SettingsRepository $repository): array /** * @return array */ - private function templateDefaults(): array + private function templateDefaults(?string $locale = null): array { $defaults = []; - foreach (SettingsMailTemplates::all() as $name => $meta) { + foreach (SettingsMailTemplates::all($locale) as $name => $meta) { $defaults[$name] = [ 'subject' => $meta['subject'], 'body' => \App\Support\EmailLayout::normalizeContent((string) $meta['body']), @@ -570,6 +579,20 @@ private function templateDefaults(): array return $defaults; } + private function resolveTemplateEditorLocale(mixed $requested): string + { + $available = \App\Support\I18n::getAvailableLocales(); + $candidate = is_string($requested) + ? \App\Support\I18n::normalizeLocaleCode($requested) + : ''; + + if ($candidate !== '' && isset($available[$candidate])) { + return $candidate; + } + + return \App\Support\I18n::resolveUserLocale(null); + } + /** * @param array{name:string, type:string, tmp_name:string, error:int, size:int} $file * @return array{success:bool, path?:string, message?:string} @@ -1283,7 +1306,7 @@ public function updateEventSettings(Request $request, Response $response, mysqli } /** - * @return array{loan_duration_days: int, pickup_expiry_days: int, max_renewals: int, max_active_loans_per_user: int, max_loan_duration_days: int, auto_approve_requests: bool, app_timezone: string} + * @return array{loan_duration_days: int, pickup_expiry_days: int, max_renewals: int, max_active_loans_per_user: int, max_loan_duration_days: int, auto_approve_requests: bool, recall_auto_enabled: bool, recall_interval_days: int, recall_max_count: int, app_timezone: string} */ private function resolveLoansSettings(SettingsRepository $repository): array { @@ -1294,6 +1317,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 +1349,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 +1363,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/Models/SettingsRepository.php b/app/Models/SettingsRepository.php index 577198479..cc43db959 100644 --- a/app/Models/SettingsRepository.php +++ b/app/Models/SettingsRepository.php @@ -293,10 +293,8 @@ public function saveEmailTemplate(string $name, string $subject, string $body, ? } /** - * Locale predefinito per letture/scritture dell'editor template (M8): - * deve coincidere con quello usato dall'invio (EmailService legge - * (name, locale_installazione)), altrimenti su installazioni non italiane - * le modifiche admin finiscono su righe it_IT mai lette dall'invio. + * Normalize the optional template locale. The multilingual editor passes an + * explicit value; legacy callers still target the installation language. */ private function resolveTemplateLocale(?string $locale): string { diff --git a/app/Routes/web.php b/app/Routes/web.php index 5a8a9395b..394e50882 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -1897,6 +1897,36 @@ 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. + // RateLimit aggiunto per primo (LIFO: eseguito per ultimo, dopo Auth e + // CSRF) come /admin/settings/email/test — ogni chiamata è un vero + // handshake SMTP. L'actionKey rende il bucket per-client e non per-path: + // senza, ogni {id} avrebbe il proprio contatore e il limite sarebbe + // aggirabile iterando i prestiti. + $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 \App\Middleware\RateLimitMiddleware(10, 60, 'loan-email-pdf'))->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 \App\Middleware\RateLimitMiddleware(10, 60, 'loan-recall'))->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + // #360: sollecito in blocco per i prestiti selezionati nella lista + // (fino a 50 invii SMTP per richiesta: limite più stretto) + $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 \App\Middleware\RateLimitMiddleware(5, 60, 'loan-bulk-recall'))->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..6ab8512a2 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', @@ -94,6 +95,11 @@ private function setupMailer(): void { } $this->mailer->SMTPSecure = $settings['smtp_security']; $this->mailer->Port = (int)$settings['smtp_port']; + // PHPMailer otherwise inherits a very generous socket timeout. + // Recall batches run in an HTTP request, so a dead SMTP peer + // must release the worker within a predictable bound. + $smtpTimeout = min(10, max(1, (int) ConfigStore::get('mail.smtp.timeout', 10))); + $this->mailer->Timeout = $smtpTimeout; } else { $this->mailer->isMail(); } @@ -158,8 +164,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 +179,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 +189,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; } @@ -254,7 +283,29 @@ private function getEmailTemplate(string $templateName, ?string $locale = null): $candidateLocales[] = 'it_IT'; } - foreach ($candidateLocales as $candidateLocale) { + foreach ($candidateLocales as $index => $candidateLocale) { + // #360: after missing a stored row for the EXACT requested + // locale, prefer the shipped default translated in that locale + // over another locale's stored row. Installs seed + // email_templates only for the installation language, so + // without this the chain always landed on the installation row + // and per-recipient localization never materialized (a German + // recipient got the seeded Italian template). Cross-locale + // admin customizations still win for locales with no shipped + // texts (hasShippedLocale gate), and an exact-locale stored + // row — customized or seeded — always wins above. + // When the next candidate is a same-language variant (it_CH -> + // it_IT) the stored row IS the recipient's language: let it + // win, customizations included. Only a language change (en_US + // recipient falling toward an it_IT row) triggers the shipped + // default. + if ($index === 1 && substr($candidateLocale, 0, 2) !== substr($locale, 0, 2) && \App\Support\SettingsMailTemplates::hasShippedLocale($locale)) { + $shipped = \App\Support\SettingsMailTemplates::get($templateName, $locale); + if ($shipped !== null) { + return ['subject' => (string) $shipped['subject'], 'body' => (string) $shipped['body']]; + } + } + $stmt = $this->db->prepare("SELECT subject, body FROM email_templates WHERE name = ? AND locale = ? AND active = 1"); $stmt->bind_param('ss', $templateName, $candidateLocale); $stmt->execute(); diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php index d742e84cb..819a8dffd 100644 --- a/app/Support/NotificationService.php +++ b/app/Support/NotificationService.php @@ -4,6 +4,7 @@ namespace App\Support; use mysqli; +use App\Models\SettingsRepository; use App\Support\ConfigStore; use App\Support\RouteTranslator; use App\Support\SettingsMailTemplates; @@ -12,33 +13,49 @@ class NotificationService { private mysqli $db; private EmailService $emailService; + /** #360: per-request cache of recipient email -> resolved email locale. */ + private array $recipientLocaleCache = []; + + /** + * #360: memoize the notification-column self-heal so a bulk path (bulkRecall + * loops sendManualRecall up to 50 times) doesn't re-run four SHOW COLUMNS + * per loan. The schema doesn't change mid-request. + */ + private bool $notificationColumnsEnsured = false; + public function __construct(mysqli $db) { $this->db = $db; $this->emailService = new EmailService($db); } /** - * Format date for email templates using installation locale + * Format date for email templates. * * @param string $dateString Date string parseable by strtotime * @param bool $includeTime Include time (H:i) in output + * @param string|null $locale Locale driving the date format; null keeps the + * historical behaviour (installation locale) * @return string Formatted date */ - private function formatEmailDate(string $dateString, bool $includeTime = false): string + private function formatEmailDate(string $dateString, bool $includeTime = false, ?string $locale = null): string { $timestamp = strtotime($dateString); if ($timestamp === false) { return $dateString; } - $locale = I18n::getInstallationLocale(); - $isItalian = str_starts_with($locale, 'it'); + $locale = $locale ?? I18n::getInstallationLocale(); - if ($isItalian) { - $format = 'd-m-Y'; - } else { - $format = 'Y-m-d'; - } + // Explicit per-language date conventions for the shipped locales; + // English and anything unknown keep the unambiguous ISO form (the + // historical behaviour for every non-Italian locale). + $formats = [ + 'it' => 'd-m-Y', + 'de' => 'd.m.Y', + 'fr' => 'd/m/Y', + 'da' => 'd.m.Y', + ]; + $format = $formats[substr($locale, 0, 2)] ?? 'Y-m-d'; if ($includeTime) { $format .= ' H:i'; @@ -47,6 +64,71 @@ private function formatEmailDate(string $dateString, bool $includeTime = false): return date($format, $timestamp); } + /** + * #360: user-facing emails render in the recipient's preferred language. + * + * Resolves utenti.locale for the given address when it is a locale this + * installation actually ships, falling back to the installation locale — + * the historical behaviour — for empty, unknown or unsupported values and + * for addresses that don't belong to a user. Trusting the column mirrors + * the app itself: the same value already drives the recipient's UI + * language (login, profile, language switcher keep it up to date). + */ + private function resolveRecipientLocale(string $email): string + { + $email = trim($email); + $fallback = I18n::getInstallationLocale(); + if ($email === '') { + return $fallback; + } + if (isset($this->recipientLocaleCache[$email])) { + return $this->recipientLocaleCache[$email]; + } + + $locale = $fallback; + $stmt = null; + try { + $stmt = $this->db->prepare("SELECT locale FROM utenti WHERE email = ? LIMIT 1"); + $stmt->bind_param('s', $email); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + + $raw = trim((string) ($row['locale'] ?? '')); + if ($raw !== '' && isset(I18n::getAvailableLocales()[$raw])) { + $locale = $raw; + } + } catch (\Throwable $e) { + // Lookup failure must never block the send: keep the fallback. + } finally { + // Close in finally so a throw between prepare() and close() can't + // leak the statement handle. + if ($stmt instanceof \mysqli_stmt) { + $stmt->close(); + } + } + + return $this->recipientLocaleCache[$email] = $locale; + } + + /** + * Resolve a __()-translated label in the given locale without leaking the + * switch to the caller's session (used for per-recipient email wording). + */ + private function translateInLocale(string $message, string $locale): string + { + $sessionLocale = I18n::getLocale(); + I18n::setLocale($locale); + // finally: a throw inside __() must never leave the process-wide locale + // switched — the automatic-notification cron translates for many + // recipients in one run, and a leaked locale would mistranslate every + // subsequent one. + try { + return __($message); + } finally { + I18n::setLocale($sessionLocale); + } + } + /** * Invia notifica per nuova registrazione agli admin */ @@ -104,21 +186,26 @@ public function sendUserRegistrationPending(int $userId): bool { } $stmt->close(); - // Use installation locale for email template - $locale = \App\Support\I18n::getInstallationLocale(); + // #360: recipient's preferred language (registration stores the + // registrant's session locale into utenti.locale, so this matches + // the language they signed up in). + $locale = $this->resolveRecipientLocale((string) $user['email']); $verifySection = ''; if (!empty($user['token_verifica_email'])) { - // Temporarily switch locale for button translation + // Temporarily switch locale for the button URL + label; the + // finally guarantees the process-wide locale is restored even + // when RouteTranslator/__() throw (the outer catch would + // otherwise leave the whole request in the recipient's locale). $currentLocale = \App\Support\I18n::getLocale(); - \App\Support\I18n::setLocale($locale); - - $verifyUrl = absoluteUrl(RouteTranslator::route('verify_email') . '?token=' . urlencode((string)$user['token_verifica_email'])); - $buttonText = __('Conferma la tua email'); - $verifySection = '

' . htmlspecialchars($buttonText, ENT_QUOTES, 'UTF-8') . '

'; - - // Restore original locale - \App\Support\I18n::setLocale($currentLocale); + try { + \App\Support\I18n::setLocale($locale); + $verifyUrl = absoluteUrl(RouteTranslator::route('verify_email') . '?token=' . urlencode((string)$user['token_verifica_email'])); + $buttonText = __('Conferma la tua email'); + $verifySection = '

' . htmlspecialchars($buttonText, ENT_QUOTES, 'UTF-8') . '

'; + } finally { + \App\Support\I18n::setLocale($currentLocale); + } } $variables = [ @@ -126,7 +213,7 @@ public function sendUserRegistrationPending(int $userId): bool { 'cognome' => $user['cognome'], 'email' => $user['email'], 'codice_tessera' => $user['codice_tessera'], - 'data_registrazione' => $this->formatEmailDate($user['created_at'], true), + 'data_registrazione' => $this->formatEmailDate($user['created_at'], true, $locale), 'sezione_verifica' => $verifySection, 'app_name' => ConfigStore::get('app.name', 'Biblioteca') ]; @@ -168,8 +255,8 @@ public function sendUserAccountApproved(int $userId): bool { 'login_url' => absoluteUrl(RouteTranslator::route('login')) ]; - // Use installation locale for email template - $locale = \App\Support\I18n::getInstallationLocale(); + // #360: recipient's preferred language (installation locale fallback) + $locale = $this->resolveRecipientLocale((string) $user['email']); return $this->emailService->sendTemplate($user['email'], 'user_account_approved', $variables, $locale); } catch (\Throwable $e) { @@ -209,8 +296,8 @@ public function sendUserActivationWithVerification(int $userId, string $token): 'app_name' => ConfigStore::get('app.name', 'Biblioteca') ]; - // Use installation locale for email template - $locale = \App\Support\I18n::getInstallationLocale(); + // #360: recipient's preferred language (installation locale fallback) + $locale = $this->resolveRecipientLocale((string) $user['email']); return $this->emailService->sendTemplate($user['email'], 'user_activation_with_verification', $variables, $locale); } catch (\Throwable $e) { @@ -250,8 +337,8 @@ public function sendUserPasswordSetup(int $userId): bool 'app_name' => ConfigStore::get('app.name', 'Biblioteca') ]; - // Use installation locale for email template - $locale = \App\Support\I18n::getInstallationLocale(); + // #360: recipient's preferred language (installation locale fallback) + $locale = $this->resolveRecipientLocale((string) $user['email']); return $this->emailService->sendTemplate($user['email'], 'user_password_setup', $variables, $locale); } catch (\Throwable $e) { @@ -292,8 +379,8 @@ public function sendAdminInvitation(int $userId): bool 'dashboard_url' => absoluteUrl('/admin/dashboard') ]; - // Use installation locale for email template - $locale = \App\Support\I18n::getInstallationLocale(); + // #360: recipient's preferred language (installation locale fallback) + $locale = $this->resolveRecipientLocale((string) $user['email']); return $this->emailService->sendTemplate($user['email'], 'admin_invitation', $variables, $locale); } catch (\Throwable $e) { @@ -408,17 +495,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); + // #360: the email renders in each recipient's language (see + // sendWithRetry), so the "oggi" label must match that locale too — + // resolved per recipient locale, cached per batch. Not the caller's + // session locale (this runs from the admin-login maintenance path + // as well as cron). + $todayLabels = []; foreach ($loans as $loan) { + $recipientLocale = $this->resolveRecipientLocale((string) $loan['utente_email']); + $todayLabels[$recipientLocale] ??= $this->translateInLocale('oggi', $recipientLocale); // ATOMIC: Mark warning as sent BEFORE sending email // Only proceed if we successfully claimed this loan (affected_rows == 1) // Re-assert data_scadenza too: renew()/update() may have moved the @@ -442,8 +528,8 @@ public function sendLoanExpirationWarnings(): int { $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza']), - 'giorni_rimasti' => $daysRemaining === 0 ? $todayLabel : (string)$daysRemaining + 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale), + 'giorni_rimasti' => $daysRemaining === 0 ? $todayLabels[$recipientLocale] : (string)$daysRemaining ]; $emailSent = $this->sendWithRetry($loan['utente_email'], 'loan_expiring_warning', $variables); @@ -539,7 +625,7 @@ public function sendOverdueLoanNotifications(): int { $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza']), + 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza'], false, $this->resolveRecipientLocale((string) $loan['utente_email'])), 'giorni_ritardo' => $loan['giorni_ritardo'] ]; @@ -576,6 +662,285 @@ 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 { + // Read the recall schedule the same way SettingsController writes and + // reads it — via SettingsRepository. ConfigStore can NOT serve loans + // keys: loadDatabaseSettings() has no 'loans' category mapping, so it + // always returns the code default (recall_auto_enabled would resolve + // to '0' regardless of the admin toggle, making this a permanent + // no-op). Every other loans setting is read via SettingsRepository + // for exactly this reason. + $settings = new SettingsRepository($this->db); + if ((string) ($settings->get('loans', 'recall_auto_enabled', '0') ?? '0') !== '1') { + return 0; + } + // Self-healing like sendManualRecall(): this is a public method and + // the SELECT below reads recall_count / last_recall_at, which a + // not-yet-migrated install doesn't have — without this the query + // throws, the catch swallows it and automatic recalls silently + // no-op. runAutomaticNotifications() heals only its own path. + $this->addNotificationColumns(); + // 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) ($settings->get('loans', 'recall_interval_days', '7') ?? 7))); + $maxRecalls = min(50, max(1, (int) ($settings->get('loans', 'recall_max_count', '3') ?? 3))); + + // "Oggi" nel timezone applicativo come parametro bound (M9). + $today = DateHelper::today(); + + // CI-SOFT-DELETE-EXEMPT: an active overdue loan remains physically + // owed to the library even when its catalog record is archived. + $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 + JOIN utenti u ON p.utente_id = u.id + WHERE p.stato IN ('in_corso', 'in_ritardo') + AND p.attivo = 1 + AND u.email IS NOT NULL + AND TRIM(u.email) <> '' + 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) ? 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 interval / + * max-count schedule, but still requires the loan to be genuinely overdue, + * the user to have an email address, and no recall to have already gone out + * today (a per-loan daily cooldown that bounds abuse of the manual path). + * + * @return array{success: bool, message: string} + */ + public function sendManualRecall(int $loanId, int $maxRetries = 3, int $retryDelayMs = 1000): array { + try { + $this->addNotificationColumns(); + + $today = DateHelper::today(); + // CI-SOFT-DELETE-EXEMPT: staff must be able to recall an outstanding + // physical loan after the related catalog record is archived. + $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 + 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.')]; + } + // Per-loan daily cooldown: at most one recall per loan per day, + // matching the automatic scheduler's DATE(last_recall_at) < today + // throttle. Prevents a staff session (or a repeated bulk submit) + // from re-emailing the same patron many times in a row while still + // allowing a manual recall on a later day. + if ($loan['last_recall_at'] !== null + && substr((string) $loan['last_recall_at'], 0, 10) === $today) { + return ['success' => false, 'message' => __('Un sollecito è già stato inviato oggi per questo prestito.')]; + } + + if ($this->claimAndSendRecall($loan, $maxRetries, $retryDelayMs)) { + 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, int $maxRetries = 3, int $retryDelayMs = 1000): 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'], false, $this->resolveRecipientLocale((string) $loan['utente_email'])), + 'giorni_ritardo' => $loan['giorni_ritardo'], + 'numero_sollecito' => $recallNumber, + ]; + + $emailSent = $this->sendWithRetry( + $loan['utente_email'], + 'loan_recall_notification', + $variables, + max(1, $maxRetries), + max(0, $retryDelayMs) + ); + + 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.')]; + } + + // Same circuit breaker as sendWithRetry, checked BEFORE generating + // the PDF: when SMTP is plainly unreachable the attachment would be + // wasted work on a synchronous request path. + if (!\App\Support\Mailer::isSmtpReachable()) { + return ['success' => false, 'message' => __('Invio email non riuscito. Controlla la configurazione email e riprova.')]; + } + + // #360: cover message in the recipient's language, like every other + // user-facing email. The PDF generator uses the process-wide I18n + // locale too, so switch only for generation and always restore the + // staff request locale afterwards. + $recipientLocale = $this->resolveRecipientLocale($email); + $requestLocale = I18n::getLocale(); + try { + I18n::setLocale($recipientLocale); + $pdfContent = (new LoanPdfGenerator($this->db))->generate($loanId); + } finally { + I18n::setLocale($requestLocale); + } + + $variables = [ + 'prestito_id' => $loanId, + 'utente_nome' => (string) ($loan['utente'] ?? ''), + 'libro_titolo' => (string) ($loan['libro'] ?? ''), + 'data_prestito' => $this->formatEmailDate((string) ($loan['data_prestito'] ?? ''), false, $recipientLocale), + 'data_scadenza' => $this->formatEmailDate((string) ($loan['data_scadenza'] ?? ''), false, $recipientLocale), + ]; + $attachment = [ + 'content' => $pdfContent, + 'filename' => 'prestito_' . $loanId . '_' . str_replace('-', '', DateHelper::today()) . '.pdf', + 'type' => 'application/pdf', + ]; + + $sent = $this->emailService->sendTemplate( + $email, + 'loan_receipt_email', + $variables, + $recipientLocale, + [$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 { @@ -676,7 +1041,7 @@ public function notifyWishlistBookAvailability(int $bookId): int { 'libro_titolo' => $wishlist['titolo'], 'libro_autore' => $wishlist['autore'] ?: 'Autore non specificato', 'libro_isbn' => $wishlist['isbn'] ?: 'N/A', - 'data_disponibilita' => $this->formatEmailDate('now', true), + 'data_disponibilita' => $this->formatEmailDate('now', true, $this->resolveRecipientLocale((string) $wishlist['email'])), 'book_url' => absoluteUrl($bookLink), 'wishlist_url' => absoluteUrl(RouteTranslator::route('wishlist')) ]; @@ -710,6 +1075,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 +1087,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) { @@ -850,6 +1219,9 @@ public function getNextAvailabilityDate(int $bookId): ?string { * Aggiunge colonne per tracking notifiche se non esistono */ private function addNotificationColumns(): void { + if ($this->notificationColumnsEnsured) { + return; + } try { // Check if columns exist $result = $this->db->query("SHOW COLUMNS FROM prestiti LIKE 'warning_sent'"); @@ -862,6 +1234,23 @@ 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"); + } + + // Only memoize once the checks completed without throwing, so a + // transient failure retries on the next call. + $this->notificationColumnsEnsured = true; } catch (\Throwable $e) { SecureLogger::error("Failed to add notification columns: " . $e->getMessage()); } @@ -894,11 +1283,15 @@ private function sendToAdmins(string $templateName, array $variables): bool { return false; } - // Use installation locale for email template - $locale = \App\Support\I18n::getInstallationLocale(); - $sentCount = 0; while ($row = $result->fetch_assoc()) { + // #360: each admin gets the template in their own language + // (utenti.locale, installation locale as fallback). Known + // limitation: date VARIABLES arrive pre-formatted by the + // caller in the installation locale (one $variables array for + // the whole fan-out), so a differently-localized admin sees + // installation-format dates inside their localized template. + $locale = $this->resolveRecipientLocale((string) $row['email']); if ($this->emailService->sendTemplate($row['email'], $templateName, $variables, $locale)) { $sentCount++; } @@ -942,11 +1335,12 @@ public function sendLoanRenewedNotification(int $loanId, int $maxRenewals): bool $stmt->close(); $remaining = max(0, $maxRenewals - (int) ($loan['renewals'] ?? 0)); + $recipientLocale = $this->resolveRecipientLocale((string) $loan['utente_email']); $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'data_fine' => $this->formatEmailDate($loan['data_scadenza']), + 'data_fine' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale), 'rinnovi_rimanenti' => (string) $remaining, ]; @@ -983,13 +1377,14 @@ public function sendLoanApprovedNotification(int $loanId): bool { $endDate = new \DateTime($loan['data_scadenza']); $days = $endDate->diff($startDate)->days; + $recipientLocale = $this->resolveRecipientLocale((string) $loan['utente_email']); $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'data_inizio' => $this->formatEmailDate($loan['data_prestito']), - 'data_fine' => $this->formatEmailDate($loan['data_scadenza']), + 'data_inizio' => $this->formatEmailDate($loan['data_prestito'], false, $recipientLocale), + 'data_fine' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale), 'giorni_prestito' => $days, - 'pickup_instructions' => __('Recati in biblioteca durante gli orari di apertura per ritirare il libro.') + 'pickup_instructions' => $this->translateInLocale('Recati in biblioteca durante gli orari di apertura per ritirare il libro.', $recipientLocale) ]; return $this->sendWithRetry($loan['utente_email'], 'loan_approved', $variables); @@ -1095,18 +1490,19 @@ public function sendPickupReadyNotification(int $loanId): bool { $endDate = new \DateTime($loan['data_scadenza']); $days = $endDate->diff($startDate)->days; + $recipientLocale = $this->resolveRecipientLocale((string) $loan['utente_email']); $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'data_inizio' => $this->formatEmailDate($loan['data_prestito']), - 'data_fine' => $this->formatEmailDate($loan['data_scadenza']), + 'data_inizio' => $this->formatEmailDate($loan['data_prestito'], false, $recipientLocale), + 'data_fine' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale), 'giorni_prestito' => $days, - 'scadenza_ritiro' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline']) : '', + 'scadenza_ritiro' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline'], false, $recipientLocale) : '', // #304: alias under the DB column name so a customised template using // {{pickup_deadline}} (the natural name a user copies from the schema) // resolves as well as the canonical {{scadenza_ritiro}}. - 'pickup_deadline' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline']) : '', - 'pickup_instructions' => __('Recati in biblioteca durante gli orari di apertura per ritirare il libro.') + 'pickup_deadline' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline'], false, $recipientLocale) : '', + 'pickup_instructions' => $this->translateInLocale('Recati in biblioteca durante gli orari di apertura per ritirare il libro.', $recipientLocale) ]; return $this->sendWithRetry($loan['utente_email'], 'loan_pickup_ready', $variables); @@ -1140,12 +1536,13 @@ public function sendPickupExpiredNotification(int $loanId): bool { } $stmt->close(); + $recipientLocale = $this->resolveRecipientLocale((string) $loan['utente_email']); $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'scadenza_ritiro' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline']) : '', + 'scadenza_ritiro' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline'], false, $recipientLocale) : '', // #304: alias under the DB column name, see sendPickupReadyNotification. - 'pickup_deadline' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline']) : '' + 'pickup_deadline' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline'], false, $recipientLocale) : '' ]; return $this->sendWithRetry($loan['utente_email'], 'loan_pickup_expired', $variables); @@ -1229,7 +1626,7 @@ public function sendLoanReturnedNotification(int $loanId): bool { $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'data_restituzione' => $this->formatEmailDate($loan['data_restituzione'] ?? DateHelper::today()), + 'data_restituzione' => $this->formatEmailDate($loan['data_restituzione'] ?? DateHelper::today(), false, $this->resolveRecipientLocale((string) $loan['utente_email'])), ]; return $this->sendWithRetry($loan['utente_email'], 'loan_returned', $variables); @@ -1270,7 +1667,7 @@ public function sendReservationExpiredNotification(int $loanId): bool { $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'data_scadenza' => $this->formatEmailDate($scadenza), + 'data_scadenza' => $this->formatEmailDate($scadenza, false, $this->resolveRecipientLocale((string) $loan['utente_email'])), ]; return $this->sendWithRetry($loan['utente_email'], 'reservation_expired', $variables); @@ -1301,7 +1698,7 @@ public function sendQueueReservationExpiredNotification(string $email, array $va return false; } if (!empty($variables['data_scadenza'])) { - $variables['data_scadenza'] = $this->formatEmailDate((string)$variables['data_scadenza']); + $variables['data_scadenza'] = $this->formatEmailDate((string)$variables['data_scadenza'], false, $this->resolveRecipientLocale($email)); } return $this->sendWithRetry($email, 'reservation_expired', $variables); } @@ -1338,9 +1735,14 @@ private function sendWithRetry(string $email, string $template, array $variables $lastError = ''; + // #360: recipient's preferred language (utenti.locale) instead of the + // installation locale; resolveRecipientLocale falls back to the + // installation locale, so nothing changes for users without one. + $recipientLocale = $this->resolveRecipientLocale($email); + for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { try { - if ($this->emailService->sendTemplate($email, $template, $variables, \App\Support\I18n::getInstallationLocale())) { + if ($this->emailService->sendTemplate($email, $template, $variables, $recipientLocale)) { if ($attempt > 1) { SecureLogger::info("Email to {$email} succeeded on attempt {$attempt}"); } diff --git a/app/Support/SettingsMailTemplates.php b/app/Support/SettingsMailTemplates.php index 793188d0b..ffa676aee 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' => [ @@ -564,6 +609,28 @@ public static function get(string $template, ?string $locale = null): ?array return $all[$template] ?? null; } + /** + * #360: whether this locale has SHIPPED template texts — the in-code + * Italian base or a mail_templates/.php override. Distinguishes a + * genuinely translated default from all()'s silent Italian fallback for + * unknown locales, so callers (EmailService::getEmailTemplate) can prefer + * a translated shipped default over another locale's stored row without + * ever serving Italian under a foreign locale's name. + */ + public static function hasShippedLocale(?string $locale): bool + { + $locale = trim((string) $locale); + if ($locale === '') { + return false; + } + if (str_starts_with($locale, 'it')) { + return true; // in-code Italian base + } + $map = ['en' => 'en_US', 'de' => 'de_DE', 'fr' => 'fr_FR', 'da' => 'da_DK', 'en_US' => 'en_US', 'de_DE' => 'de_DE', 'fr_FR' => 'fr_FR', 'da_DK' => 'da_DK']; + $key = $map[$locale] ?? ($map[substr($locale, 0, 2)] ?? null); + return $key !== null && is_file(__DIR__ . '/mail_templates/' . $key . '.php'); + } + /** * @return string[] */ @@ -612,6 +679,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..e4acee1bf 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

    +

    Guten Tag {{utente_nome}},

    +

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

    + +
    +

    ❗️ Handlung erforderlich

    +

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

    +
    +

    Falls Sie das Buch bereits zurückgegeben haben, betrachten Sie diese Nachricht bitte als gegenstandslos.

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

    Ausleihbeleg

    +

    Guten Tag {{utente_nome}},

    +

    im Anhang finden Sie den PDF-Beleg Ihrer Ausleihe:

    + +

    Bewahren Sie 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/auth/register.php b/app/Views/auth/register.php index f88a87848..2d5327031 100644 --- a/app/Views/auth/register.php +++ b/app/Views/auth/register.php @@ -282,6 +282,35 @@ class="w-full px-4 py-3 rounded-xl border border-gray-300 bg-white text-gray-900 + 1): + ?> +
    + + +

    +
    + +
    +
    + + +
    + +
    + + +
    +
    @@ -151,6 +161,31 @@ class="px-4 py-2 bg-red-600 text-white hover:bg-red-500 rounded-lg transition-co + + + + + + + @@ -175,7 +210,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 +378,107 @@ 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(); + // #360: the CSRF middleware rejects an expired session / bad token + // with { error, code } (not { message }); tell the user to reload + // instead of showing a generic "send failed", per the app-wide + // data.code===SESSION_EXPIRED|CSRF_INVALID convention. + if (data.error || data.code) { + Swal.fire({ + title: __('Errore'), + text: data.error || failureText, + icon: 'error', + confirmButtonText: __('OK'), + confirmButtonColor: '#111827' + }); + if (data.code === 'SESSION_EXPIRED' || data.code === 'CSRF_INVALID') { + setTimeout(() => window.location.reload(), 2000); + } + return; + } + 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..586c1c81f 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"> +