From da8d9c41ed3d62cccb7fa7e8c96ad6fe1d8b073e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 25 Jun 2026 07:35:20 +0200 Subject: [PATCH 1/2] Fix mobile API Android contract coherence --- app/Controllers/PluginController.php | 37 ++++ app/Views/admin/plugins.php | 12 +- .../src/Controllers/ActionsController.php | 10 +- .../src/Controllers/CatalogController.php | 29 +++- .../src/Controllers/OpenApiController.php | 163 +++++++++++------- tests/mobile-api-behaviors.spec.js | 21 +++ tests/mobile-api-idempotency.spec.js | 2 +- 7 files changed, 208 insertions(+), 66 deletions(-) diff --git a/app/Controllers/PluginController.php b/app/Controllers/PluginController.php index 39ec429f..d2f6d8aa 100644 --- a/app/Controllers/PluginController.php +++ b/app/Controllers/PluginController.php @@ -40,9 +40,36 @@ public function index(Request $request, Response $response): Response $plugins = $this->pluginManager->getAllPlugins(); $pluginSettings = []; + // Which plugins expose a settings page (so the list can show a generic + // "Impostazioni" button for ANY such plugin, not just a hardcoded few — + // otherwise e.g. the Mobile API gate is unreachable from the UI). + $pluginHasSettings = []; foreach ($plugins as $plugin) { $settings = $this->pluginManager->getSettings((int) $plugin['id']); + // Settings-page detection: only meaningful for active plugins (the + // instance must be loaded). Mirror the settings-route guard. + $hasSettingsPage = false; + if (!empty($plugin['is_active'])) { + try { + $instance = $this->pluginManager->getPluginInstance((int) $plugin['id']); + if ($instance !== null + && is_callable([$instance, 'hasSettingsPage']) + && $instance->hasSettingsPage() + && is_callable([$instance, 'getSettingsViewPath']) + ) { + // Mirror settingsPage()'s guard exactly: a declared view + // path that doesn't exist on disk must NOT surface a button + // (the click would 404). + $viewPath = $instance->getSettingsViewPath(); + $hasSettingsPage = is_string($viewPath) && is_file($viewPath); + } + } catch (\Throwable $e) { + $hasSettingsPage = false; + } + } + $pluginHasSettings[$plugin['id']] = $hasSettingsPage; + // Handle Google Books API key if (array_key_exists('google_books_api_key', $settings)) { $settings['google_books_api_key_exists'] = $settings['google_books_api_key'] !== ''; @@ -300,6 +327,16 @@ public function updateSettings(Request $request, Response $response, array $args return $response->withHeader('Content-Type', 'application/json')->withStatus(404); } + // Self-rendering settings pages (e.g. Mobile API) post flat form fields and + // handle their OWN POST inside the view — CSRF, persistence via the plugin's + // saveSettings(), success message, re-render. The legacy AJAX handlers below + // require a nested `settings` payload; when it's absent, this is such a + // self-handling form, so render the settings page (which runs that logic) + // instead of falling through to "questo plugin non supporta impostazioni". + if (!is_array($body) || !array_key_exists('settings', $body)) { + return $this->settingsPage($request, $response, $args); + } + error_log('[PluginController] Plugin name: ' . $plugin['name']); $settings = $body['settings'] ?? []; diff --git a/app/Views/admin/plugins.php b/app/Views/admin/plugins.php index 1ef8d3b3..4ddc9ac8 100644 --- a/app/Views/admin/plugins.php +++ b/app/Views/admin/plugins.php @@ -5,6 +5,8 @@ $pageTitle = __('Gestione Plugin'); $pluginSettings = $pluginSettings ?? []; +/** @var array $pluginHasSettings */ +$pluginHasSettings = $pluginHasSettings ?? []; ?>
@@ -296,7 +298,15 @@ class="px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 tr - + + diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index d725b63d..f28dc349 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -210,9 +210,13 @@ public function requestReservation(Request $request, ResponseInterface $response // Decide loan vs reservation using the SAME availability gate the web // uses (ReservationManager), so the two surfaces agree. Immediate loan - // only when the user asked for "now" (no date) and a copy is free. + // when the user asked for "now" — either no date, OR today's date with + // a copy free. The app's "Request loan" on an available title sends + // today (its date picker pre-selects the first free day = today), so + // without the today case that flow wrongly became a reservation + // instead of a pending loan. A FUTURE date is always a reservation. $immediate = false; - if ($desired === '') { + if ($desired === '' || $desired === date('Y-m-d')) { $manager = new \App\Controllers\ReservationManager($this->db); $immediate = $manager->isBookAvailableForImmediateLoan($bookId, null, null, $userId); } @@ -635,7 +639,7 @@ public function sendMessage(Request $request, ResponseInterface $response): Resp $nome = trim((string) ($body['nome'] ?? ($user['nome'] ?? ''))); $cognome = trim((string) ($body['cognome'] ?? ($user['cognome'] ?? ''))); $email = trim((string) ($body['email'] ?? ($user['email'] ?? ''))); - $messaggio = trim((string) ($body['messaggio'] ?? $body['message'] ?? '')); + $messaggio = trim((string) ($body['messaggio'] ?? $body['message'] ?? $body['body'] ?? '')); if ($nome === '' || $cognome === '' || $email === '' || $messaggio === '') { return ResponseEnvelope::error($response, 'required_fields', __('Compila tutti i campi obbligatori.'), 422); diff --git a/storage/plugins/mobile-api/src/Controllers/CatalogController.php b/storage/plugins/mobile-api/src/Controllers/CatalogController.php index bad316b3..73693a6c 100644 --- a/storage/plugins/mobile-api/src/Controllers/CatalogController.php +++ b/storage/plugins/mobile-api/src/Controllers/CatalogController.php @@ -496,7 +496,10 @@ public function bookAvailability( 'total_copies' => (int) ($avail['total_copies'] ?? 0), 'earliest_available' => $avail['earliest_available'] ?? null, 'unavailable_dates' => array_values((array) ($avail['unavailable_dates'] ?? [])), - 'days' => array_values((array) ($avail['days'] ?? [])), + 'days' => $this->normalizeMobileAvailabilityDays( + array_values((array) ($avail['days'] ?? [])), + (int) ($avail['total_copies'] ?? 0) + ), ], []); } catch (\Throwable $e) { SecureLogger::error('[MobileApi] book availability failed: ' . $e->getMessage()); @@ -504,6 +507,30 @@ public function bookAvailability( } } + /** + * Convert the website availability labels (borrowed/reserved) into the + * app calendar contract: free, partial, full. + * + * @param list> $days + * @return list> + */ + private function normalizeMobileAvailabilityDays(array $days, int $totalCopies): array + { + foreach ($days as &$day) { + $available = (int) ($day['available'] ?? 0); + if ($available <= 0 || $totalCopies <= 0) { + $day['state'] = 'full'; + } elseif ($available < $totalCopies) { + $day['state'] = 'partial'; + } else { + $day['state'] = 'free'; + } + } + unset($day); + + return $days; + } + private function fetchBookCore(int $bookId): ?array { $sql = " diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index f80dce53..d7913cf9 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -471,7 +471,7 @@ private function paths(): array 'available' => ['type' => 'integer'], 'loaned' => ['type' => 'integer'], 'reserved' => ['type' => 'integer'], - 'state' => ['type' => 'string', 'enum' => ['free', 'borrowed', 'reserved']], + 'state' => ['type' => 'string', 'enum' => ['free', 'partial', 'full']], ], ]], ], @@ -828,12 +828,16 @@ private function registerRequestSchema(): array { return [ 'type' => 'object', - 'required' => ['nome', 'cognome', 'email', 'password'], + 'required' => ['nome', 'cognome', 'email', 'telefono', 'indirizzo', 'password', 'password_confirm', 'privacy_acceptance'], 'properties' => [ - 'nome' => ['type' => 'string', 'maxLength' => 100], - 'cognome' => ['type' => 'string', 'maxLength' => 100], - 'email' => ['type' => 'string', 'format' => 'email'], - 'password' => ['type' => 'string', 'minLength' => 8], + 'nome' => ['type' => 'string', 'maxLength' => 100], + 'cognome' => ['type' => 'string', 'maxLength' => 100], + 'email' => ['type' => 'string', 'format' => 'email', 'maxLength' => 255], + 'telefono' => ['type' => 'string'], + 'indirizzo' => ['type' => 'string'], + 'password' => ['type' => 'string', 'minLength' => 8, 'maxLength' => 72], + 'password_confirm' => ['type' => 'string', 'minLength' => 8, 'maxLength' => 72], + 'privacy_acceptance' => ['type' => 'boolean'], ], ]; } @@ -872,16 +876,20 @@ private function bookSummarySchema(): array return [ 'type' => 'object', 'properties' => [ - 'id' => ['type' => 'integer'], - 'titolo' => ['type' => 'string'], - 'sottotitolo' => ['type' => 'string', 'nullable' => true], - 'autori' => ['type' => 'array', 'items' => ['type' => 'string']], - 'editore' => ['type' => 'string', 'nullable' => true], - 'anno' => ['type' => 'integer', 'nullable' => true], - 'copertina_url' => ['type' => 'string', 'format' => 'uri', 'nullable' => true, 'description' => 'Absolute URL.'], - 'disponibile' => ['type' => 'boolean', 'description' => 'True if at least one copy is currently loanable.'], - 'genere' => ['type' => 'string', 'nullable' => true], - 'lingua' => ['type' => 'string', 'nullable' => true], + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'subtitle' => ['type' => 'string', 'nullable' => true], + 'author' => ['type' => 'string', 'nullable' => true], + 'publisher' => ['type' => 'string', 'nullable' => true], + 'genre' => ['type' => 'string', 'nullable' => true], + 'year' => ['type' => 'integer', 'nullable' => true], + 'language' => ['type' => 'string', 'nullable' => true], + 'media_type' => ['type' => 'string', 'nullable' => true], + 'isbn13' => ['type' => 'string', 'nullable' => true], + 'cover_url' => ['type' => 'string', 'format' => 'uri', 'nullable' => true, 'description' => 'Absolute URL.'], + 'copies_total' => ['type' => 'integer'], + 'copies_available' => ['type' => 'integer'], + 'loanable_now' => ['type' => 'boolean', 'description' => 'True if at least one copy is currently loanable.'], ], ]; } @@ -893,16 +901,47 @@ private function bookDetailSchema(): array 'allOf' => [['$ref' => '#/components/schemas/BookSummary']], 'type' => 'object', 'properties' => [ - 'isbn10' => ['type' => 'string', 'nullable' => true], - 'isbn13' => ['type' => 'string', 'nullable' => true], - 'ean' => ['type' => 'string', 'nullable' => true], - 'descrizione' => ['type' => 'string', 'nullable' => true], - 'numero_pagine' => ['type' => 'integer', 'nullable' => true], - 'condizione' => ['type' => 'string', 'nullable' => true], - 'collocazione' => ['type' => 'string', 'nullable' => true, 'description' => 'Shelf / location label.'], - 'copie_totali' => ['type' => 'integer', 'description' => 'Total physical copies.'], - 'copie_disponibili' => ['type' => 'integer', 'description' => 'Copies currently loanable.'], - 'personal_history' => ['$ref' => '#/components/schemas/PersonalHistory'], + 'isbn10' => ['type' => 'string', 'nullable' => true], + 'ean' => ['type' => 'string', 'nullable' => true], + 'pages' => ['type' => 'integer', 'nullable' => true], + 'description' => ['type' => 'string', 'nullable' => true], + 'format' => ['type' => 'string', 'nullable' => true], + 'series' => ['type' => 'string', 'nullable' => true], + 'condition' => ['type' => 'string', 'nullable' => true], + 'audio_url' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], + 'has_audio' => ['type' => 'boolean'], + 'ebook_url' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], + 'ebook_format' => ['type' => 'string', 'nullable' => true], + 'has_ebook' => ['type' => 'boolean'], + 'genre' => ['type' => 'object', 'nullable' => true, 'properties' => [ + 'id' => ['type' => 'integer', 'nullable' => true], + 'name' => ['type' => 'string', 'nullable' => true], + 'parent' => ['type' => 'string', 'nullable' => true], + 'grandparent' => ['type' => 'string', 'nullable' => true], + 'subgenre' => ['type' => 'string', 'nullable' => true], + ]], + 'publishers' => ['type' => 'array', 'items' => ['type' => 'object', 'properties' => [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + ]]], + 'authors' => ['type' => 'array', 'items' => ['type' => 'object', 'properties' => [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'role' => ['type' => 'string', 'nullable' => true], + ]]], + 'availability' => ['type' => 'object', 'properties' => [ + 'copies_total' => ['type' => 'integer'], + 'copies_available' => ['type' => 'integer'], + 'loanable_now' => ['type' => 'boolean'], + 'state' => ['type' => 'string', 'enum' => ['available', 'on_loan', 'reserved', 'unavailable']], + ]], + 'location' => ['type' => 'object', 'nullable' => true, 'properties' => [ + 'label' => ['type' => 'string', 'nullable' => true], + 'shelf_id' => ['type' => 'integer', 'nullable' => true], + 'shelf_unit_id' => ['type' => 'integer', 'nullable' => true], + 'position' => ['type' => 'integer', 'nullable' => true], + ]], + 'personal_history' => ['$ref' => '#/components/schemas/PersonalHistory'], ], ]; } @@ -918,6 +957,7 @@ private function personalHistorySchema(): array 'has_reserved' => ['type' => 'boolean', 'description' => 'The user has a pending/active reservation for this book.'], 'has_wishlisted' => ['type' => 'boolean', 'description' => 'This book is in the user\'s wishlist.'], 'has_active_loan' => ['type' => 'boolean', 'description' => 'The user currently has this book on loan.'], + 'has_pending_request' => ['type' => 'boolean', 'description' => 'The user has a pending loan request for this book.'], ], ]; } @@ -929,8 +969,7 @@ private function genreNodeSchema(): array 'type' => 'object', 'properties' => [ 'id' => ['type' => 'integer'], - 'nome' => ['type' => 'string'], - 'livello' => ['type' => 'integer', 'description' => 'Depth: 1 = root, 2 = mid, 3 = leaf.'], + 'name' => ['type' => 'string'], 'children' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/GenreNode']], ], ]; @@ -955,15 +994,15 @@ private function loanItemSchema(): array return [ 'type' => 'object', 'properties' => [ - 'id' => ['type' => 'integer'], - 'libro_id' => ['type' => 'integer'], - 'titolo' => ['type' => 'string'], - 'copertina_url' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], - 'stato' => ['type' => 'string', 'description' => 'e.g. in_corso, concluso, in_scadenza, scaduto, prenotato, in_attesa.'], - 'data_prestito' => ['type' => 'string', 'format' => 'date', 'nullable' => true], - 'data_scadenza' => ['type' => 'string', 'format' => 'date', 'nullable' => true], - 'data_restituzione' => ['type' => 'string', 'format' => 'date', 'nullable' => true], - 'created_at' => ['type' => 'string', 'format' => 'date-time'], + 'id' => ['type' => 'integer'], + 'book_id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'cover_url' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], + 'status' => ['type' => 'string', 'description' => 'Raw prestiti.stato value.'], + 'loaned_at' => ['type' => 'string', 'format' => 'date', 'nullable' => true], + 'due_at' => ['type' => 'string', 'format' => 'date', 'nullable' => true], + 'returned_at' => ['type' => 'string', 'format' => 'date', 'nullable' => true], + 'renewals' => ['type' => 'integer', 'nullable' => true], ], ]; } @@ -974,15 +1013,16 @@ private function reservationItemSchema(): array return [ 'type' => 'object', 'properties' => [ - 'id' => ['type' => 'integer'], - 'libro_id' => ['type' => 'integer'], - 'titolo' => ['type' => 'string'], - 'copertina_url' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], - 'stato' => ['type' => 'string'], - 'data_inizio' => ['type' => 'string', 'format' => 'date', 'nullable' => true], - 'data_fine' => ['type' => 'string', 'format' => 'date', 'nullable' => true], - 'queue_position'=> ['type' => 'integer', 'nullable' => true], - 'created_at' => ['type' => 'string', 'format' => 'date-time'], + 'id' => ['type' => 'integer'], + 'book_id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'cover_url' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], + 'status' => ['type' => 'string'], + 'queue_position' => ['type' => 'integer', 'nullable' => true], + 'requested_from' => ['type' => 'string', 'format' => 'date', 'nullable' => true], + 'requested_to' => ['type' => 'string', 'format' => 'date', 'nullable' => true], + 'reserved_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true], + 'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true], ], ]; } @@ -994,9 +1034,10 @@ private function reservationRequestSchema(): array 'type' => 'object', 'required' => ['book_id'], 'properties' => [ - 'book_id' => ['type' => 'integer'], - 'start_date' => ['type' => 'string', 'format' => 'date', 'description' => 'Requested start date (ISO-8601). Defaults to today if omitted.'], - 'end_date' => ['type' => 'string', 'format' => 'date', 'description' => 'Requested end date (ISO-8601).'], + 'book_id' => ['type' => 'integer'], + 'desired_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'description' => 'Requested start date. Today or omitted means immediate loan when a copy is free; future dates create reservations.'], + 'start_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'deprecated' => true], + 'end_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'deprecated' => true], ], ]; } @@ -1007,11 +1048,13 @@ private function wishlistItemSchema(): array return [ 'type' => 'object', 'properties' => [ - 'book_id' => ['type' => 'integer'], - 'titolo' => ['type' => 'string'], - 'copertina_url' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], - 'disponibile' => ['type' => 'boolean'], - 'added_at' => ['type' => 'string', 'format' => 'date-time'], + 'book_id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'author' => ['type' => 'string', 'nullable' => true], + 'year' => ['type' => 'integer', 'nullable' => true], + 'cover_url' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], + 'copies_available' => ['type' => 'integer'], + 'loanable_now' => ['type' => 'boolean'], ], ]; } @@ -1061,10 +1104,11 @@ private function changePasswordRequestSchema(): array { return [ 'type' => 'object', - 'required' => ['current_password', 'new_password'], + 'required' => ['current_password', 'password', 'password_confirm'], 'properties' => [ 'current_password' => ['type' => 'string'], - 'new_password' => ['type' => 'string', 'minLength' => 8], + 'password' => ['type' => 'string', 'minLength' => 8, 'maxLength' => 72], + 'password_confirm' => ['type' => 'string', 'minLength' => 8, 'maxLength' => 72], ], ]; } @@ -1091,10 +1135,9 @@ private function notificationItemSchema(): array 'id' => ['type' => 'string', 'description' => 'Opaque notification identifier.'], 'type' => ['type' => 'string', 'enum' => ['loan_due', 'loan_overdue', 'reservation_ready', 'new_message', 'book_available']], 'title' => ['type' => 'string'], - 'body' => ['type' => 'string'], - 'read' => ['type' => 'boolean'], - 'created_at' => ['type' => 'string', 'format' => 'date-time'], - 'payload' => ['type' => 'object', 'additionalProperties' => true, 'nullable' => true], + 'message' => ['type' => 'string'], + 'book_id' => ['type' => 'integer', 'nullable' => true], + 'date' => ['type' => 'string', 'nullable' => true, 'description' => 'ISO date or date-time associated with the notification.'], ], ]; } diff --git a/tests/mobile-api-behaviors.spec.js b/tests/mobile-api-behaviors.spec.js index 672e26a0..fd437796 100644 --- a/tests/mobile-api-behaviors.spec.js +++ b/tests/mobile-api-behaviors.spec.js @@ -600,4 +600,25 @@ test.describe('Reservations', () => { clearBorrowerReservations(); } }); + + test('26) {available book, desired_date=today} → immediate loan (type=loan), not a reservation', async ({ request }) => { + // The app's "Request loan" on an AVAILABLE title sends today's date (its + // date picker pre-selects the first free day = today). The backend must + // treat today + a free copy as an immediate pending loan, not a + // reservation — otherwise the available-now flow silently queues instead + // of requesting a loan. A FUTURE date stays a reservation (test 22). + clearBorrowerReservations(); + try { + const book = pickAvailableBook(); + const res = await call(request, 'POST', '/reservations', { + token: ctx.userToken, + body: { book_id: book, desired_date: todayYmd() }, + }); + expect(res.status(), 'created').toBe(201); + const j = await jsonOf(res); + expect(j?.data?.type, 'today + available → immediate loan').toBe('loan'); + } finally { + clearBorrowerReservations(); + } + }); }); diff --git a/tests/mobile-api-idempotency.spec.js b/tests/mobile-api-idempotency.spec.js index d9b439d1..42da530c 100644 --- a/tests/mobile-api-idempotency.spec.js +++ b/tests/mobile-api-idempotency.spec.js @@ -313,7 +313,7 @@ test.describe('Mobile API — two calls per endpoint (idempotency + ETag/304)', } catch { ctx.reservationId = 0; } // Pre-add the wishlist book so DELETE /me/wishlist/{bookId} has something to remove on call #1. - try { dbExec(`INSERT IGNORE INTO wishlist (utente_id, libro_id, created_at) VALUES (${ctx.userId}, ${ctx.bookId}, NOW())`); } catch {} + try { dbExec(`INSERT IGNORE INTO wishlist (utente_id, libro_id) VALUES (${ctx.userId}, ${ctx.bookId})`); } catch {} }); test.afterAll(async () => { From da0508dde025a8679d8e828874b1653769093ed7 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 25 Jun 2026 08:23:57 +0200 Subject: [PATCH 2/2] fix(mobile-api): correct the OpenAPI MessageRequest schema to match the real /messages contract messageRequestSchema declared required:[subject, body] with only subject/body, but ActionsController::sendMessage never reads `subject` and takes the message text from `messaggio` (aliases `message`/`body`), with nome/cognome/email defaulting to the authenticated user and optional telefono/indirizzo. Document the real contract so clients (the Android app) follow an accurate spec. Runtime was already working via the `body` alias; this fixes the spec accuracy flagged by the contract-coherence review. Verified: PHPStan L5, live /api/v1/openapi.json now reflects the real fields. --- .../src/Controllers/OpenApiController.php | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index d7913cf9..26c30d05 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -1116,12 +1116,24 @@ private function changePasswordRequestSchema(): array /** @return array */ private function messageRequestSchema(): array { + // Mirror the real ActionsController::sendMessage contract: the message + // text is `messaggio` (aliases `message` / `body` are accepted); nome/ + // cognome/email default to the authenticated user when omitted; telefono/ + // indirizzo are optional. The previous schema wrongly required + // subject/body — the controller never reads `subject`. return [ - 'type' => 'object', - 'required' => ['subject', 'body'], - 'properties' => [ - 'subject' => ['type' => 'string', 'maxLength' => 255], - 'body' => ['type' => 'string', 'maxLength' => 5000], + 'type' => 'object', + 'required' => ['messaggio'], + 'description' => 'nome/cognome/email default to the authenticated user when omitted. The message text is `messaggio` (aliases: `message`, `body`).', + 'properties' => [ + 'messaggio' => ['type' => 'string', 'maxLength' => 5000, 'description' => 'Message text.'], + 'message' => ['type' => 'string', 'maxLength' => 5000, 'description' => 'Alias of `messaggio`.'], + 'body' => ['type' => 'string', 'maxLength' => 5000, 'description' => 'Alias of `messaggio`.'], + 'nome' => ['type' => 'string', 'maxLength' => 100], + 'cognome' => ['type' => 'string', 'maxLength' => 100], + 'email' => ['type' => 'string', 'format' => 'email'], + 'telefono' => ['type' => 'string'], + 'indirizzo' => ['type' => 'string'], ], ]; }