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
6 changes: 3 additions & 3 deletions _contract/MOBILE_API_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ All FKs respect existing `utenti`/`libri` schema. Follow the soft-delete rule on
## Endpoint manifest (`/api/v1`)

**Public (no token):**
- `GET /health` — discovery: `{ name, logo, version, api_version, features{...}, app_access_enabled, registration_enabled, private_mode }`.
- `GET /health` — discovery: `{ name, logo, version, api_version, features{...}, loan_approval_required, app_access_enabled, registration_enabled, private_mode }`.
- `GET /openapi.json` — OpenAPI 3.1 document.
- `GET /docs` — Swagger UI page.
- `POST /auth/login` — `{ email, password, device_name, device_id, platform }` → `{ token, user{...} }`. Throttled.
Expand All @@ -77,8 +77,8 @@ All FKs respect existing `utenti`/`libri` schema. Follow the soft-delete rule on
- `DELETE /catalog/books/{id}/reviews` — delete the current user's review (idempotent).
- `GET /me/reviews` — the user's own reviews across all titles (`book_id`, `book_title`, `book_author`, `cover_url`, `rating`, `text`, timestamps); cursor pagination.
- `GET /catalog/genres` — genre cascade tree (for filter UI).
- `GET /me/loans` — own loans (active + history). `GET /me/reservations`.
- `POST /reservations` — request a loan/reservation (honor existing overlap/availability rules). `DELETE /reservations/{id}` — cancel own pending reservation.
- `GET /me/loans` — own loans (active + history), including `status_label`, `requested_at`, `due_attention`, and the server-authoritative `cancellable` hint. `GET /me/reservations`.
- `POST /reservations` — request a loan/reservation (honor existing overlap/availability rules); the 201 payload declares `type=loan|reservation`, `status`, and `auto_approved` so the client reflects the actual routing outcome. `DELETE /reservations/{id}` cancels an own pending reservation; `DELETE /loans/{id}` unambiguously cancels an own pending, scheduled, or `da_ritirare` loan.
- `GET /me/wishlist`. `POST /me/wishlist` `{book_id}`. `DELETE /me/wishlist/{book_id}`.
- `POST /messages` — send a contact message (same as web contact form).
- `GET /me/notifications` — in-app notification feed (fallback when push off).
Expand Down
17 changes: 17 additions & 0 deletions _contract/endpoint-manifest.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ const ENDPOINTS = [
{ name: 'POST /reservations', method: 'POST', path: '/reservations', auth: true, kind: 'conflict2',
body: (ctx) => ({ book_id: ctx.bookId }), firstAny: true /* 1st may 201 or 422 (availability); 2nd identical must be rejected */ },
{ name: 'DELETE /reservations/{reservationId}', method: 'DELETE', path: '/reservations/{reservationId}', auth: true, kind: 'gone2' },
{ name: 'DELETE /loans/{loanId}', method: 'DELETE', path: '/loans/{loanId}', auth: true, kind: 'gone2' },
{ name: 'GET /me/wishlist', method: 'GET', path: '/me/wishlist', auth: true, kind: 'safeGet' },
{ name: 'POST /me/wishlist', method: 'POST', path: '/me/wishlist', auth: true, kind: 'write2xx',
body: (ctx) => ({ book_id: ctx.bookId }) /* adding twice must not duplicate; both 2xx */ },
Expand Down Expand Up @@ -303,6 +304,21 @@ test.describe('Mobile API — two calls per endpoint (idempotency + ETag/304)',
ctx.reservationId = parseInt(dbScalar(`SELECT id FROM prenotazioni WHERE utente_id=${ctx.userId} ORDER BY id DESC LIMIT 1`) || '0', 10);
} catch { ctx.reservationId = 0; }

// A dedicated pending loan for the unambiguous Mobile API 1.4.4
// DELETE /loans/{id} route. Prefer a different title from the active
// reservation above so cross-table duplicate guards cannot pre-empt it.
try {
const loanBookId = parseInt(dbScalar(
`SELECT id FROM libri WHERE deleted_at IS NULL AND id != ${ctx.bookId} ORDER BY id LIMIT 1`
) || String(ctx.bookId), 10);
dbExec(`INSERT INTO prestiti
(utente_id, libro_id, data_prestito, data_scadenza, stato, attivo, created_at)
VALUES (${ctx.userId}, ${loanBookId}, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), 'pendente', 0, NOW())`);
ctx.loanId = parseInt(dbScalar(
`SELECT id FROM prestiti WHERE utente_id=${ctx.userId} AND stato='pendente' ORDER BY id DESC LIMIT 1`
) || '0', 10);
} catch { ctx.loanId = 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) VALUES (${ctx.userId}, ${ctx.bookId})`); } catch {}
});
Expand All @@ -312,6 +328,7 @@ test.describe('Mobile API — two calls per endpoint (idempotency + ETag/304)',
try { dbExec(`DELETE FROM mobile_push_subscriptions WHERE user_id=${ctx.userId}`); } catch {}
try { dbExec(`DELETE FROM wishlist WHERE utente_id=${ctx.userId}`); } catch {}
try { dbExec(`DELETE FROM prenotazioni WHERE utente_id=${ctx.userId}`); } catch {}
try { dbExec(`DELETE FROM prestiti WHERE utente_id=${ctx.userId}`); } catch {}
try { dbExec(`DELETE FROM utenti WHERE id=${ctx.userId}`); } catch {}
await page?.close();
});
Expand Down
63 changes: 60 additions & 3 deletions _contract/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,15 @@
"type": "string",
"description": "Raw prestiti.stato value."
},
"status_label": {
"type": "string",
"description": "Server-localized fallback label for the raw status."
},
"requested_at": {
"type": ["string", "null"],
"format": "date",
"description": "Date the loan request was created."
},
"loaned_at": {
"type": "string",
"format": "date",
Expand All @@ -740,6 +749,10 @@
"format": "date",
"nullable": true
},
"due_attention": {
"type": "boolean",
"description": "Library-timezone cue that the active loan is due today or earlier."
},
"returned_at": {
"type": "string",
"format": "date",
Expand All @@ -748,6 +761,10 @@
"renewals": {
"type": "integer",
"nullable": true
},
"cancellable": {
"type": "boolean",
"description": "True when DELETE /loans/{id} currently accepts this owned row."
}
}
},
Expand Down Expand Up @@ -1117,6 +1134,10 @@
"type": "boolean",
"description": "True when the instance is in catalogue-only mode (loans, reservations and wishlist disabled)."
},
"loan_approval_required": {
"type": "boolean",
"description": "False when immediate requests are automatically approved."
},
"app_access_enabled": {
"type": "boolean"
},
Expand Down Expand Up @@ -2402,7 +2423,7 @@
"loans"
],
"summary": "Request a reservation / loan",
"description": "Honors existing overlap, availability, and max-active-loans rules (same as the web form). Returns error codes for overlap, unavailable, or queue position.",
"description": "Honors existing overlap, availability, max-active-loans, and automatic-approval settings. The 201 payload declares the actual loan or FIFO-reservation outcome.",
"operationId": "postReservation",
"security": [
{
Expand All @@ -2421,11 +2442,25 @@
},
"responses": {
"201": {
"description": "Reservation created.",
"description": "Reservation or loan request created.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Envelope"
"allOf": [
{"$ref": "#/components/schemas/Envelope"}
],
"properties": {
"data": {
"type": "object",
"properties": {
"type": {"type": "string", "enum": ["loan", "reservation"]},
"book_id": {"type": "integer"},
"loan_id": {"type": "integer", "nullable": true},
"auto_approved": {"type": "boolean", "nullable": true},
"status": {"type": "string", "nullable": true, "enum": ["pendente", "da_ritirare"]}
}
}
}
}
}
}
Expand Down Expand Up @@ -2512,6 +2547,28 @@
}
}
},
"/loans/{id}": {
"delete": {
"tags": ["loans"],
"summary": "Cancel own cancellable loan request",
"description": "Cancels an owned pending, scheduled, or ready-for-pickup loan through an id-space-safe route.",
"operationId": "deleteLoan",
"security": [{"bearerAuth": []}],
"parameters": [{
"name": "id",
"in": "path",
"required": true,
"schema": {"type": "integer"}
}],
"responses": {
"200": {"description": "Loan cancelled."},
"401": {"$ref": "#/components/responses/Unauthorized"},
"404": {"$ref": "#/components/responses/NotFound"},
"409": {"description": "Loan can no longer be cancelled."},
"500": {"$ref": "#/components/responses/InternalError"}
}
}
},
"/me/wishlist": {
"get": {
"tags": [
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ android {
applicationId = "com.pinakes.app"
minSdk = 26
targetSdk = 35
versionCode = 12
versionName = "1.4.2"
versionCode = 13
versionName = "1.4.3"

vectorDrawables {
useSupportLibrary = true
Expand Down
61 changes: 61 additions & 0 deletions app/src/main/java/com/pinakes/app/PinakesApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import com.pinakes.app.data.network.NetworkEntryPoint
import dagger.hilt.android.EntryPointAccessors
import okhttp3.OkHttpClient
import dagger.hilt.android.HiltAndroidApp
import io.sentry.Hint
import io.sentry.SentryEvent
import io.sentry.SentryOptions
import io.sentry.TypeCheckHint
import io.sentry.android.core.SentryAndroid
import okhttp3.Request
import okhttp3.Response
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand Down Expand Up @@ -43,6 +49,17 @@ class PinakesApplication : Application(), ImageLoaderFactory {
options.isDebug = false
options.isSendDefaultPii = false // no IP / user data attached by default
options.tracesSampleRate = 0.0 // crash reporting only — no performance tracing

// Sentry's OkHttp auto-instrumentation reports every backend HTTP error as an
// error-level SentryHttpClientException. This app talks to the user's OWN
// self-hosted server, so a backend outage is the server's state, not an app
// bug. Drop the two clearly-not-our-fault cases so they don't create noise:
// the health probe (whose whole job is to detect a down server) and transient
// upstream 5xx (502 bad gateway / 503 unavailable / 504 timeout). A real 500
// or a 4xx (which can point at an app-side request bug) still comes through.
options.beforeSend = SentryOptions.BeforeSendCallback { event, hint ->
if (isExpectedBackendHttpFailure(event, hint)) null else event
}
}

// Refresh the cached catalog every time the app comes to the foreground, so the
Expand All @@ -63,6 +80,28 @@ class PinakesApplication : Application(), ImageLoaderFactory {
CatalogSyncWorker.schedule(this)
}

/**
* True when a Sentry event is a backend HTTP failure that reflects the user's own
* server being unavailable rather than a bug in this app: a failed `/health` probe,
* or a transient upstream 5xx (502/503/504). Such events are pure noise for an app
* that points at self-hosted instances, so [onCreate]'s `beforeSend` drops them.
*
* Classification is driven by Sentry's OkHttp integration data (the request/response
* carried on the [Hint], and the structured `contexts.response.statusCode`), never by
* matching exception text — so a non-HTTP crash whose message happens to mention an
* HTTP status is never discarded.
*/
private fun isExpectedBackendHttpFailure(event: SentryEvent, hint: Hint): Boolean {
val response = hint.getAs(TypeCheckHint.OKHTTP_RESPONSE, Response::class.java)
val request = hint.getAs(TypeCheckHint.OKHTTP_REQUEST, Request::class.java)
// Only OkHttp-instrumented HTTP failures carry these signals.
val isHttpResponseFailure =
response != null || request != null || event.contexts.response?.statusCode != null
val statusCode = response?.code ?: event.contexts.response?.statusCode
val url = request?.url?.toString() ?: event.request?.url
return isExpectedBackendFailure(isHttpResponseFailure, statusCode, url)
}

/**
* App-wide Coil loader with a persistent 256 MB disk cache that ignores server cache
* headers, so book covers are downloaded once and reused across sessions instead of
Expand Down Expand Up @@ -91,3 +130,25 @@ class PinakesApplication : Application(), ImageLoaderFactory {
.build()
}
}

/**
* Pure classification for the Sentry `beforeSend` filter, extracted so it can be unit
* tested without constructing SDK/OkHttp objects.
*
* @param isHttpResponseFailure whether the event is an OkHttp-instrumented HTTP-response
* failure at all (false for any non-HTTP crash → never dropped, whatever its message).
* @param statusCode the structured HTTP status, or null when unknown.
* @param url the request URL, or null when unknown.
* @return true only for a failure that reflects the user's server state rather than an
* app bug: a `/health` probe (any status), or a transient upstream 5xx (502/503/504).
*/
internal fun isExpectedBackendFailure(
isHttpResponseFailure: Boolean,
statusCode: Int?,
url: String?,
): Boolean {
if (!isHttpResponseFailure) return false
val path = (url ?: "").substringBefore('?').trimEnd('/')
if (path.endsWith("/health")) return true
return statusCode == 502 || statusCode == 503 || statusCode == 504
}
38 changes: 38 additions & 0 deletions app/src/main/java/com/pinakes/app/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ data class HealthPayload(
// When true the instance is a read-only browsable catalog: loans, reservations and
// wishlist are disabled (and the matching booleans in [features] are false).
@SerialName("catalogue_mode") val catalogueMode: Boolean = false,
// Server-authoritative approval mode (Mobile API since Pinakes 0.7.46).
@SerialName("loan_approval_required") val loanApprovalRequired: Boolean = true,
@SerialName("app_access_enabled") val appAccessEnabled: Boolean = false,
@SerialName("registration_enabled") val registrationEnabled: Boolean = false,
@SerialName("private_mode") val privateMode: Boolean = false,
Expand Down Expand Up @@ -339,10 +341,19 @@ data class LoanItem(
val title: String = "",
@SerialName("cover_url") val coverUrl: String? = null,
val status: String = "", // in_corso | concluso | in_scadenza | scaduto | prenotato | in_attesa
// Additive Mobile API 1.4.3 fallback for states unknown to this app version.
@SerialName("status_label") val statusLabel: String? = null,
// Date the request was created. This is the honest timeline date for
// cancelled/expired requests that never became a physical loan.
@SerialName("requested_at") val requestedAt: String? = null,
@SerialName("loaned_at") val loanedAt: String? = null,
@SerialName("due_at") val dueAt: String? = null,
// Computed in the library timezone; do not re-derive from device LocalDate.
@SerialName("due_attention") val dueAttention: Boolean = false,
@SerialName("returned_at") val returnedAt: String? = null,
val renewals: Int? = null,
// Mobile API 1.4.4 presentation hint; DELETE /loans/{id} revalidates it.
val cancellable: Boolean = false,
)

@Serializable
Expand Down Expand Up @@ -389,6 +400,33 @@ data class ReservationRequest(
@SerialName("end_date") val endDate: String? = null, // legacy/compat, yyyy-MM-dd
)

/** Authoritative result of POST /reservations (loan request or FIFO reservation). */
@Serializable
data class CirculationRequestResult(
val type: String = "", // loan | reservation
@SerialName("book_id") val bookId: Int = 0,
@SerialName("loan_id") val loanId: Int? = null,
@SerialName("auto_approved") val autoApproved: Boolean? = null,
val status: String? = null, // pendente | da_ritirare
) {
val kind: CirculationRequestKind
get() = when {
type == "reservation" -> CirculationRequestKind.Reservation
type == "loan" && (autoApproved == true || status == "da_ritirare") ->
CirculationRequestKind.LoanReadyForPickup
type == "loan" && (autoApproved == false || status in setOf("pendente", "in_attesa")) ->
CirculationRequestKind.LoanPending
else -> CirculationRequestKind.Unknown
}
}

enum class CirculationRequestKind {
Reservation,
LoanPending,
LoanReadyForPickup,
Unknown,
}

// ---------- Wishlist ----------
@Serializable
data class WishlistItem(
Expand Down
7 changes: 6 additions & 1 deletion app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.pinakes.app.data.model.BookReviews
import com.pinakes.app.data.model.BookSummary
import com.pinakes.app.data.model.CatalogLanguage
import com.pinakes.app.data.model.ChangePasswordRequest
import com.pinakes.app.data.model.CirculationRequestResult
import com.pinakes.app.data.model.DeviceItem
import com.pinakes.app.data.model.Envelope
import com.pinakes.app.data.model.ForgotRequest
Expand Down Expand Up @@ -158,11 +159,15 @@ interface PinakesApi {
suspend fun reservations(): Envelope<List<ReservationItem>>

@POST("reservations")
suspend fun createReservation(@Body body: ReservationRequest): Envelope<Unit>
suspend fun createReservation(@Body body: ReservationRequest): Envelope<CirculationRequestResult>

@DELETE("reservations/{id}")
suspend fun cancelReservation(@Path("id") id: Int): Envelope<Unit>

/** Explicit, id-space-safe cancellation for pending/scheduled/ready loans (#381). */
@DELETE("loans/{id}")
suspend fun cancelLoan(@Path("id") id: Int): Envelope<Unit>

// ---- Wishlist ----
@GET("me/wishlist")
suspend fun wishlist(): Envelope<List<WishlistItem>>
Expand Down
Loading
Loading