Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions app/Controllers/AutoriApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ public function list(Request $request, Response $response, mysqli $db): Response
$selectNaz = $colNaz !== null ? "a.`$colNaz` AS nazionalita" : "'' AS nazionalita";
$sql_prepared = "SELECT a.id, a.nome, a.pseudonimo, a.data_nascita, a.data_morte, a.biografia, a.sito_web,
$selectNaz,
(SELECT COUNT(*) FROM libri_autori la WHERE la.autore_id = a.id) AS libri_count
(SELECT COUNT(*)
FROM libri_autori la
JOIN libri l ON l.id = la.libro_id AND l.deleted_at IS NULL
WHERE la.autore_id = a.id) AS libri_count
FROM autori a $where_prepared ORDER BY $orderColumn $orderDir LIMIT ?, ?";

$stmt = $db->prepare($sql_prepared);
Expand Down Expand Up @@ -377,7 +380,10 @@ public function bulkExport(Request $request, Response $response, mysqli $db): Re

$sql = "SELECT a.id, a.nome, a.pseudonimo, a.data_nascita, a.data_morte, a.sito_web,
$selectNaz,
(SELECT COUNT(*) FROM libri_autori la WHERE la.autore_id = a.id) AS libri_count
(SELECT COUNT(*)
FROM libri_autori la
JOIN libri l ON l.id = la.libro_id AND l.deleted_at IS NULL
WHERE la.autore_id = a.id) AS libri_count
FROM autori a
WHERE a.id IN ($placeholders)
ORDER BY a.nome ASC";
Expand Down
3 changes: 2 additions & 1 deletion app/Controllers/FrontendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1054,7 +1054,8 @@ private function getFilterOptions(mysqli $db, array $filters = []): array
LEFT JOIN generi gfp ON gf.parent_id = gfp.id
LEFT JOIN generi gfpp ON gfp.parent_id = gfpp.id
LEFT JOIN generi sg ON l.sottogenere_id = sg.id
WHERE (
WHERE l.deleted_at IS NULL
AND (
l.genere_id = g.id
OR l.sottogenere_id = g.id
OR l.genere_id IN (SELECT id FROM generi WHERE parent_id = g.id)
Expand Down
7 changes: 5 additions & 2 deletions app/Controllers/LoanApprovalController.php
Original file line number Diff line number Diff line change
Expand Up @@ -526,10 +526,13 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R

$db->commit();

// Send appropriate notification to user
// Send appropriate notification to user. Issue #301 explicitly keeps
// the approval email in the auto-approval flow; manual immediate
// approvals retain the more specific pickup-ready notification.
try {
$notificationService = new \App\Support\NotificationService($db);
if ($isFutureLoan) {
$automaticApproval = (bool) $request->getAttribute('automatic_loan_approval', false);
if ($isFutureLoan || $automaticApproval) {
// Future loan: send general approval notification
$notificationService->sendLoanApprovedNotification($loanId);
} else {
Expand Down
7 changes: 6 additions & 1 deletion app/Controllers/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1278,7 +1278,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}
* @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}
*/
private function resolveLoansSettings(SettingsRepository $repository): array
{
Expand All @@ -1288,6 +1288,7 @@ private function resolveLoansSettings(SettingsRepository $repository): array
'max_renewals' => (int) ($repository->get('loans', 'max_renewals', '3') ?? 3),
'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(),
];
}

Expand All @@ -1311,12 +1312,16 @@ public function updateLoansSettings(Request $request, Response $response, mysqli
$maxRenewals = min(100, max(0, (int) ($data['max_renewals'] ?? 3))); // 0 … 100
$maxActiveLoans = min(1000, max(0, (int) ($data['max_active_loans_per_user'] ?? 0))); // 0 (unlimited) … 1000
$maxLoanDuration = min(3650, max(1, (int) ($data['max_loan_duration_days'] ?? 90))); // 1 day … 10 years (reservation-window cap enforced by ReservationsController)
$autoApprove = isset($data['auto_approve_requests'])
&& is_scalar($data['auto_approve_requests'])
&& (string) $data['auto_approve_requests'] === '1';

$repository->set('loans', 'loan_duration_days', (string) $loanDurationDays);
$repository->set('loans', 'pickup_expiry_days', (string) $pickupExpiryDays);
$repository->set('loans', 'max_renewals', (string) $maxRenewals);
$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');

$_SESSION['success_message'] = __('Impostazioni prestiti aggiornate correttamente.');
return $response->withHeader('Location', url('/admin/settings?tab=loans'))->withStatus(302);
Expand Down
56 changes: 54 additions & 2 deletions app/Controllers/UserActionsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Controllers\ReservationManager;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Psr7\Response as SlimResponse;

class UserActionsController
{
Expand Down Expand Up @@ -585,7 +586,9 @@ public function loan(Request $request, Response $response, mysqli $db): Response
}
}

// Insert as 'pendente' - requires admin approval
// Always create the canonical pending request first. When automatic
// approval is enabled, the same race-safe approval controller used by
// admins promotes it immediately after request creation.
$stmt = $db->prepare("INSERT INTO prestiti (libro_id, utente_id, data_prestito, data_scadenza, stato, attivo) VALUES (?, ?, ?, ?, 'pendente', 0)");
$stmt->bind_param('iiss', $libroId, $utenteId, $data_prestito, $data_scadenza);

Expand All @@ -599,6 +602,11 @@ public function loan(Request $request, Response $response, mysqli $db): Response
$stmt->close();
$db->commit();

// Promote immediately before performing slower email I/O, minimizing
// the post-commit window in which another request could claim the
// same copy. The canonical approval path re-checks every constraint.
$autoApproved = $this->autoApproveLoanRequest($request, $db, $newLoanId);

// Notify admins about new loan request (outside transaction)
try {
$notificationService = new \App\Support\NotificationService($db);
Expand All @@ -607,7 +615,11 @@ public function loan(Request $request, Response $response, mysqli $db): Response
SecureLogger::warning(__('Notifica richiesta prestito fallita'), ['error' => $e->getMessage()]);
}

return $this->back($response, ['loan_request_success' => 1]);
return $this->back($response, [
'loan_request_success' => 1,
'loan_id' => $newLoanId,
'auto_approved' => $autoApproved ? 1 : 0,
]);

} catch (\Throwable $e) {
$db->rollback();
Expand Down Expand Up @@ -763,6 +775,46 @@ private function back(Response $response, array $params): Response
return $response->withHeader('Location', $referer . $sep . $qs)->withStatus(302);
}

/**
* Promote a newly-created request through the canonical approval pipeline.
* A failure deliberately leaves the request pending, so an administrator can
* still process it instead of losing an otherwise valid request.
*/
private function autoApproveLoanRequest(Request $request, mysqli $db, int $loanId): bool
{
$settings = new \App\Models\SettingsRepository($db);
if (!$settings->autoApproveLoanRequests()) {
return false;
}

try {
$approvalRequest = $request
->withParsedBody(['loan_id' => $loanId])
->withAttribute('automatic_loan_approval', true);
$result = (new LoanApprovalController())->approveLoan(
$approvalRequest,
new SlimResponse(),
$db
);

if ($result->getStatusCode() >= 200 && $result->getStatusCode() < 300) {
return true;
}

SecureLogger::warning('Automatic loan approval left request pending', [
'loan_id' => $loanId,
'status' => $result->getStatusCode(),
]);
} catch (\Throwable $e) {
SecureLogger::warning('Automatic loan approval failed; request left pending', [
'loan_id' => $loanId,
'error' => $e->getMessage(),
]);
}

return false;
}

/**
* Validate referer URL to prevent open redirect attacks
*/
Expand Down
10 changes: 10 additions & 0 deletions app/Models/SettingsRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ public function daysBeforeExpiryWarning(): int
return max(0, (int)($this->get('advanced', 'days_before_expiry_warning', '3') ?? 3));
}

/**
* Whether user loan requests should skip the manual approval queue.
* Kept here so the web flow and the Mobile API share the same default and
* interpretation of the persisted setting.
*/
public function autoApproveLoanRequests(): bool
{
return ($this->get('loans', 'auto_approve_requests', '0') ?? '0') === '1';
}

/**
* @return array<string,string>
*/
Expand Down
4 changes: 3 additions & 1 deletion app/Views/frontend/book-detail.php
Original file line number Diff line number Diff line change
Expand Up @@ -1857,7 +1857,9 @@ class="book-cover-large img-fluid"
<div id="book-alerts">
<?php if (!empty($_GET['loan_request_success'])): ?>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check-circle me-2"></i><?= __("Prestito richiesto con successo.") ?>
<i class="fas fa-check-circle me-2"></i><?= !empty($_GET['auto_approved'])
? __("Prestito approvato. Il libro è in attesa di ritiro.")
: __("Prestito richiesto con successo.") ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="<?= __('Chiudi') ?>"></button>
</div>
<?php elseif (!empty($_GET['loan_error'])): ?>
Expand Down
39 changes: 39 additions & 0 deletions app/Views/settings/loans-tab.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,45 @@
<form action="<?= htmlspecialchars(url('/admin/settings/loans'), ENT_QUOTES, 'UTF-8') ?>" method="post" class="space-y-6">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8'); ?>">

<!-- Automatic approval -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-4">
<h2 class="text-xl font-semibold text-gray-900 flex items-center gap-2">
<i class="fas fa-check-double text-gray-500"></i>
<?= __("Approvazione automatica") ?>
</h2>
<p class="text-sm text-gray-600"><?= __("Approva automaticamente le richieste di prestito e passa subito allo stato in attesa di ritiro.") ?></p>
<div class="bg-gray-50 border border-gray-200 rounded-xl p-4">
<div class="flex items-start gap-2">
<i class="fas fa-info-circle text-gray-600 mt-0.5"></i>
<div class="text-xs text-gray-700">
<?= __("Le email per la nuova richiesta agli amministratori e per l'approvazione all'utente continueranno a essere inviate.") ?>
</div>
</div>
</div>
</div>
<div class="bg-white border border-gray-200 rounded-2xl p-5 space-y-4 max-sm:!bg-transparent max-sm:!border-0 max-sm:!rounded-none max-sm:!shadow-none max-sm:!p-0">
<div class="flex items-center justify-between gap-4">
<div>
<span id="auto_approve_requests_label" class="text-sm font-semibold text-gray-900"><?= __("Auto-approva le richieste") ?></span>
<p id="auto_approve_requests_desc" class="text-xs text-gray-600 mt-1"><?= __("Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.") ?></p>
</div>
<label class="relative inline-flex items-center cursor-pointer shrink-0">
<input type="checkbox"
id="auto_approve_requests"
name="auto_approve_requests"
value="1"
aria-labelledby="auto_approve_requests_label"
aria-describedby="auto_approve_requests_desc"
<?php echo !empty($loansSettings['auto_approve_requests']) ? 'checked' : ''; ?>
class="toggle-checkbox sr-only">
<div class="toggle-bg w-11 h-6 bg-gray-200 rounded-full transition-colors"></div>
<div class="toggle-dot absolute top-[2px] left-[2px] bg-white border border-gray-300 rounded-full h-5 w-5 transition-transform"></div>
</label>
</div>
</div>
</div>

<!-- Loan duration -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-4">
Expand Down
4 changes: 4 additions & 0 deletions installer/database/data_da_DK.sql
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc
('loans', 'max_loan_duration_days', '90', 'Maksimal lånevarighed, der kan anmodes om, i dage')
ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW();

INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES
('loans', 'auto_approve_requests', '0', 'Godkend låneanmodninger automatisk')
ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW();

INSERT INTO `generi` VALUES (1,'Prosa',NULL,'2025-10-20 16:20:00','2025-10-20 16:20:00',NULL);
INSERT INTO `generi` VALUES (2,'Poesi',NULL,'2025-10-20 16:20:00','2025-10-20 16:20:00',NULL);
INSERT INTO `generi` VALUES (3,'Teater',NULL,'2025-10-20 16:20:00','2025-10-20 16:20:00',NULL);
Expand Down
4 changes: 4 additions & 0 deletions installer/database/data_de_DE.sql
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc
('loans', 'max_loan_duration_days', '90', 'Maximal anforderbare Ausleihdauer in Tagen')
ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW();

INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES
('loans', 'auto_approve_requests', '0', 'Ausleihanfragen automatisch genehmigen')
ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW();

-- ============================================================================
-- System Settings - Complete default configuration
-- These replace the old storage/settings.json file
Expand Down
4 changes: 4 additions & 0 deletions installer/database/data_en_US.sql
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc
('loans', 'max_loan_duration_days', '90', 'Maximum requestable loan duration in days')
ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW();

INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES
('loans', 'auto_approve_requests', '0', 'Automatically approve loan requests')
ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW();

-- ============================================================================
-- System Settings - Complete default configuration
-- These replace the old storage/settings.json file
Expand Down
4 changes: 4 additions & 0 deletions installer/database/data_fr_FR.sql
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc
('loans', 'max_loan_duration_days', '90', 'Durée maximale demandable pour un emprunt en jours')
ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW();

INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES
('loans', 'auto_approve_requests', '0', 'Approuver automatiquement les demandes de prêt')
ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW();

-- ============================================================================
-- System Settings - Complete default configuration
-- These replace the old storage/settings.json file
Expand Down
4 changes: 4 additions & 0 deletions installer/database/data_it_IT.sql
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `desc
('loans', 'max_loan_duration_days', '90', 'Durata massima richiedibile per un prestito in giorni')
ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW();

INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`) VALUES
('loans', 'auto_approve_requests', '0', 'Approva automaticamente le richieste di prestito')
ON DUPLICATE KEY UPDATE description = VALUES(description), updated_at = NOW();

INSERT INTO `generi` VALUES (1,'Prosa',NULL,'2025-10-20 16:20:00','2025-10-20 16:20:00',NULL);
INSERT INTO `generi` VALUES (2,'Poesia',NULL,'2025-10-20 16:20:00','2025-10-20 16:20:00',NULL);
INSERT INTO `generi` VALUES (3,'Teatro',NULL,'2025-10-20 16:20:00','2025-10-20 16:20:00',NULL);
Expand Down
7 changes: 7 additions & 0 deletions installer/database/migrations/migrate_0.7.46.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Migration 0.7.46
-- Issue #301: optional automatic approval of immediate loan requests.
-- Safe and idempotent: existing administrators keep the manual workflow.

INSERT INTO `system_settings` (`category`, `setting_key`, `setting_value`, `description`)
VALUES ('loans', 'auto_approve_requests', '0', 'Approva automaticamente le richieste di prestito')
ON DUPLICATE KEY UPDATE `setting_value` = `setting_value`;
8 changes: 7 additions & 1 deletion locale/da_DK.json
Original file line number Diff line number Diff line change
Expand Up @@ -6652,5 +6652,11 @@
"Titolo della recensione": "Anmeldelsens titel",
"Email dell'utente": "Brugerens e-mail",
"Nome completo dell'utente": "Brugerens fulde navn",
"Link alla lista dei desideri": "Link til ønskelisten"
"Link alla lista dei desideri": "Link til ønskelisten",
"Approvazione automatica": "Automatisk godkendelse",
"Approva automaticamente le richieste di prestito e passa subito allo stato in attesa di ritiro.": "Godkend låneanmodninger automatisk, og flyt dem direkte til afventer afhentning.",
"Le email per la nuova richiesta agli amministratori e per l'approvazione all'utente continueranno a essere inviate.": "E-mails om den nye anmodning til administratorer og om godkendelsen til låneren sendes fortsat.",
"Auto-approva le richieste": "Godkend anmodninger automatisk",
"Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.": "Spring manuel godkendelse over for nye lån, som brugerne anmoder om.",
"Prestito approvato. Il libro è in attesa di ritiro.": "Lånet er godkendt. Bogen afventer afhentning."
}
8 changes: 7 additions & 1 deletion locale/de_DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -6652,5 +6652,11 @@
"Titolo della recensione": "Titel der Rezension",
"Email dell'utente": "E-Mail des Benutzers",
"Nome completo dell'utente": "Vollständiger Name des Benutzers",
"Link alla lista dei desideri": "Link zur Wunschliste"
"Link alla lista dei desideri": "Link zur Wunschliste",
"Approvazione automatica": "Automatische Genehmigung",
"Approva automaticamente le richieste di prestito e passa subito allo stato in attesa di ritiro.": "Genehmigt Ausleihanfragen automatisch und setzt sie direkt auf Abholung ausstehend.",
"Le email per la nuova richiesta agli amministratori e per l'approvazione all'utente continueranno a essere inviate.": "Die E-Mails zur neuen Anfrage an die Verwaltung und zur Genehmigung an die ausleihende Person werden weiterhin versendet.",
"Auto-approva le richieste": "Anfragen automatisch genehmigen",
"Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.": "Überspringt die manuelle Genehmigung neuer Ausleihanfragen.",
"Prestito approvato. Il libro è in attesa di ritiro.": "Ausleihe genehmigt. Das Buch wartet auf Abholung."
}
Loading
Loading