Skip to content

Circulation: patron types, rules matrix, reservation limits, card duration (0.9.0) - #508

Closed
fabiodalez-dev wants to merge 52 commits into
feat/patron-cards-importfrom
feat/circulation-policy
Closed

fabiodalez-dev wants to merge 52 commits into
feat/patron-cards-importfrom
feat/circulation-policy

Conversation

@fabiodalez-dev

Copy link
Copy Markdown
Owner

Patron types and per-type/per-material circulation rules (SLiMS Member Types + Loan Rules), reservation limits and automatic card duration.

Phase 0 — one source for the rules (pure refactor, own commit). New App\Services\CirculationPolicy returning final readonly UserPolicy/LoanPolicy; pure PolicyResolver with the precedence rule(type, material) → rule(type, *) → rule(all, material) → type → global setting. Every place that read loan_duration_days, max_active_loans_per_user, max_renewals, max_loan_duration_days (website, admin, approval, queue promotion, reservations, mobile-api, NCIP, plus a few the plan missed) now goes through it; the active-loan states are one shared constant. A new CI step ("Circulation policy guard") rejects direct reads outside the service. An equivalence unit test compares every old read site with the new service (50,598 checks). With no patron type configured the behaviour is exactly the same as before.

Phase 1 — patron types and the rules matrix. Tables tipologie_lettore and regole_prestito (sentinels 0 = all types, '*' = all materials, so the UNIQUE key works without NULLs), utenti.tipologia_id (FK ON DELETE SET NULL), copie.solo_consultazione. The migration seeds a default "Standard" type with every field empty (inherits the global settings) and touches no user; role (tipo_utente) and patron type stay separate concepts. Admin tab "Circolazione" in settings (types CRUD, order, default; matrix with inherited values greyed, days per renewal), patron type select + effective rules summary in the user forms, list filter and export column, "Solo consultazione" on the book page, per-copy consultation flag. New refusal codes media_limit_reached, not_loanable, reservations_disabled, reservation_limit_reached in LoanEligibility, mobile-api CirculationOutcome and NCIP (Item Does Not Circulate / Maximum Check Outs Exceeded). The renew button on the book page now shows the real renewal days instead of a fixed "+14".

Phase 2 — reservations and card. Types without reservations lose the reserve button and are refused everywhere; reservation limit = queue reservations + pending loan requests, staff can override explicitly. Card expiry computed from the type on creation, self-registration and approval ("calcola" checkbox), "Rinnova tessera" on the reader page with ActivityLog, optional card_expiring email (loans.card_expiry_warning_days, 0 = off) sent once per expiry by the cron.

Integration with the PRs below: user CSV import assigns the patron type by code (active types only, unknown codes reported per row); statistics show loans by patron type; the MARC migration section now builds the import_logs.import_type ENUM from the current values so section order does not matter.

Choices made: existing premium users are not migrated to a type; material dimension = tipo_media; regole_prestito.prestabile is nullable so a rule that only sets a limit does not re-enable a material made consultation-only by a broader rule.

Tests: E2E circulation-patron-types 9, circulation-rules-matrix 10, circulation-consultation 11, circulation-interop 8 (mobile API + NCIP), circulation-reservations 10, circulation-card 15; user-import 7 and statistics-090 7 extended; regression loan-reservation 21, mobile-api-circulation 22, ncip-server 32, inventory-stocktake 9, marc-bulk-import 7, full-test 162 passed / 9 skipped (install phase). Unit: equivalence, resolver, migration section (real SQL twice on a sandbox). PHPStan 0.

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 2 minutes.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — two small rough edges inline.

Reviewed changes

Initial review of the full circulation policy stack: one rules service, patron types and the rules matrix, consultation-only copies, reservation limits and the card duration. Coverage: I read the controllers, services, plugins, migration and schema line by line. The views, locale files and tests got only a targeted check (escaping and form field names), not a full read.

  • CirculationPolicy + PolicyResolver — a pure resolver applies the order rule(type, media) → rule(type, *) → rule(all, media) → patron type → globals, field by field. Every former direct read of loans.* goes through it, and a CI grep guard blocks new direct reads.
  • Gates rewired — staff store/update/renew, approval, reader requests (web and app), queue promotion (totalLoanLimitSql), CapacityService, NCIP checkout/request/extend and the mobile API CirculationOutcome tables all use the shared loanLimitError() / reservationLimitError() codes.
  • Consultation-only copies — lendableCopySql() is added to every copy allocation and capacity count. CopyController::toggleConsultation locks book then copy, and refuses a copy that is still held by a loan or a request.
  • Patron types and matrix admin — CirculationSettingsController routes sit behind AdminAuthMiddleware + CsrfMiddleware (settings.manage). Integer inputs are range-checked, and column names come only from constants.
  • Card duration — PatronTypeAssignment sets the expiry on create, registration and approval. "Rinnova tessera" has its own route. The card_expiring mail is sent once per expiry date, using GET_LOCK per patron and the ack column card_expiry_warning_sent_for.
  • Migration — the guarded, idempotent "Circulation policy" section matches schema.sql. The MARC import_type enum is now built from the current COLUMN_TYPE, so the order of the sections no longer matters.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread app/Support/PatronTypeAssignment.php Resolved
Comment thread app/Controllers/SettingsController.php Resolved
@pullfrog

pullfrog Bot commented Oct 28, 2026

Copy link
Copy Markdown

Important

Follow-up to the Pullfrog review: the enforcement-parity check finished after that review was posted and found a queue-promotion defect.

⚠️ Queue promotion can get stuck behind a patron that the per-material rules refuse

In ReservationManager::processBookAvailability, the candidate SQL at app/Controllers/ReservationManager.php L212-218 only skips patrons at their total limit (totalLoanLimitSql('u')).

The re-check under the user lock at L251-257 calls loanLimitError(forLoan(...)), which can also return media_limit_reached or not_loanable. In that case $nextReservation becomes null, and the LIMIT 1 query never tries the next patron.

How this plays out:

  • A patron at the head of the queue for a DVD already holds their allowed DVDs, or their patron type was later made consultation-only for that material.
  • Every return then promotes nobody, so the patrons behind them wait until the head patron frees a slot.
  • CapacityService::activeReservationIntervals (L458-481) uses the same total-only predicate, so it keeps counting that reservation as holding capacity.

The comment at L248-250 says the SQL predicate exists to avoid head-of-line blocking. With this PR, that only holds for the total limit.

Technical details
# Queue promotion head-of-line blocking on per-material rules

## Affected sites
- app/Controllers/ReservationManager.php:212-218 — candidate predicate covers only `totalLoanLimitSql('u')`
- app/Controllers/ReservationManager.php:251-257 — PHP re-check also rejects `media_limit_reached` / `not_loanable`, then sets `$nextReservation = null` with no retry
- app/Services/CapacityService.php:458-481 — the same total-only predicate decides which reservations occupy capacity; it must stay identical to the promotion predicate

## Required outcome
- A queued patron refused by a per-material limit or a loanability rule must not stop the next eligible patron from being promoted.
- The promotion predicate and the CapacityService occupancy predicate stay identical (as the existing CapacityService comment requires).

## Suggested approach (optional)
- Either extend the SQL predicate with the media limit and loanability of (patron type, `libri.tipo_media`) via a helper next to `totalLoanLimitSql()`, or loop over candidates (excluding refused reservation ids) until one passes `loanLimitError()`, bounded like the existing guard loops.

## Open questions for the human (optional)
- Should a reservation whose patron type is now consultation-only for this material be cancelled, or stay in the queue?

ℹ️ Minor notes from the same check

  • NCIP request length. NCIP RequestItem (NcipServerPlugin.php L1168) uses durationDays. The website and app use requestDurationDays(), i.e. min(duration, maxRequestDays). NCIP was uncapped before this PR too, so this is not a regression.
  • Mobile loan list queries. StaffLoanReader::maxRenewalsFor() runs forLoan() for every row of the mobile loan list. That adds one or two uncached copy queries per row.

Everything else checked out:

  • precedence, and the null/0 semantics (0 = no limit)
  • with no configuration, the results are the same as before
  • the limits are enforced on staff, approval, reader web/app, admin reservations and NCIP

Pullfrog  | View workflow run | via Pullfrog | Using claude-opus-5-5 | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. The two inline threads and the three follow-up points from the last review are fixed.

Reviewed changes

Two commits since the last review at c9f629d: bff3f4d and cdd157b.

  • Queue promotion skips patrons refused for the material. The new CirculationPolicy::materialRefusedReservationUserIds() lists patrons whose type cannot borrow the title's material, or who already hold the per-material limit. ReservationManager::processBookAvailability now skips them as candidates. CapacityService::activeReservationIntervals no longer counts their reservations as held capacity, but only while those reservations are promotable. This mirrors the existing total-limit pause, so the two predicates stay aligned, and the loanLimitError(forLoan(...)) check under the user lock is still the final gate. With no matrix rules, nothing changes.
  • Deactivated patron types are refused on the user form. PatronTypeAssignment::fromSubmission now rejects a deactivated type unless the user already has it.
  • NCIP RequestItem uses the request window. The due date now comes from requestDurationDays(), the same as the website and the app.
  • StaffLoanReader::maxRenewalsFor no longer queries per row. It resolves from patron type and material via the memoised patronTypeOf()/mediaOf(). This is equivalent: the consultation layer only changes loanable, never maxRenewals.
  • Fixed the garbled comment on the card_expiry_warning_days line in SettingsController::updateLoansSettings.
  • Added tests/circulation-queue-material-rules.unit.php. It covers the per-material limit, a material the type cannot borrow, an unchanged queue head when nobody is refused, and the fromSubmission cases.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

Follow-up to the Pullfrog enforcement-parity notes:

  • Queue promotion behind a patron the per-material rules refuse: fixed in bff3f4d. CirculationPolicy::materialRefusedReservationUserIds() lists the patrons with an active reservation on the title who are at the limit for its material or whose type may not borrow it. ReservationManager::processBookAvailability() skips them in the candidate SQL, and CapacityService::activeReservationIntervals() leaves their promotable reservations out of the occupied capacity, so both predicates stay the same. The reservation stays in the queue (not cancelled) and regains its place once the patron is eligible again, like a patron at the total limit. With no rules-matrix rows the list is empty, so a zero-config install is unchanged. tests/circulation-queue-material-rules.unit.php covers both refusal kinds plus the unchanged case, and fails without the fix.
  • NCIP RequestItem length: fixed in cdd157b, it now uses requestDurationDays() like the website and the app.
  • Mobile staff loan list queries: fixed in cdd157b. StaffLoanReader::maxRenewalsFor() resolves the limit from the memoised patron type and material, with no per-row copy query (a consultation copy changes whether a title is lent, not its renewal limit).

@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 27 minutes.

@fabiodalez-dev
fabiodalez-dev force-pushed the feat/patron-cards-import branch 2 times, most recently from 6825635 to 4ae6f89 Compare October 28, 2026 05:31
@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review skipped: 201 files exceed the limit of 100.

@fabiodalez-dev
fabiodalez-dev force-pushed the feat/patron-cards-import branch 2 times, most recently from 93f7848 to b17224d Compare October 28, 2026 08:40
@fabiodalez-dev
fabiodalez-dev force-pushed the feat/circulation-policy branch from aee6cd4 to 3e181d3 Compare October 28, 2026 09:32
@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

Security fix in b032ce7: every CirculationSettingsController action (tab redirect, type page, create, edit, delete, set default, order, rules matrix) now checks Permissions::mayConfigure() inline, as the closures calendar does, because AdminAuthMiddleware also admits staff. A staff account is sent back to the dashboard and nothing changes. New tests/circulation-settings-access.spec.js (5 tests) covers both pages and all six POSTs as staff, and checks the administrator keeps full access. The same asymmetry existed for the fine and debt-block keys in the Loans tab; #510 fixes it (39b2692).

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues. The server-side guard is complete; one UX gap is noted inline.

Reviewed changes

One commit since the last reviewed head 3e181d3: b032ce7.

  • Circulation settings limited to administrators. CirculationSettingsController::guard() checks Permissions::mayConfigure(), which passes administrators and custom roles that hold settings.manage. All eight actions call it: index, editForm, createType, updateType, deleteType, setDefault, moveType and saveRules. A refused request sets the flash message and redirects to /admin/dashboard before any read or write. Each action starts with the guard, so nothing is changed before the check.
  • Added tests/circulation-settings-access.spec.js. It checks that staff are refused on both GET pages and all six POST forms (via a CRC snapshot of tipologie_lettore/regole_prestito), that the administrator can still edit and delete, and that staff can still reach /admin/loans.
  • Added the CHANGELOG entry "Administrators only".

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

@fabiodalez-dev
fabiodalez-dev force-pushed the feat/patron-cards-import branch from 3e075d5 to 5add09a Compare October 28, 2026 10:29
Loan duration, request window, renewals and the active-loan limit were read
from the loans settings in about twenty places: the staff desk, reader
requests on the website and in the app, approval, queue promotion, renewals
and the NCIP server. They now go through App\Services\CirculationPolicy,
which returns immutable UserPolicy/LoanPolicy value objects resolved by the
pure PolicyResolver, and every due date comes from
CirculationPolicy::dueDateFor().

This is a pure refactor: with only the global settings every number is the
one the old read sites computed. The active-loan state set is one shared
constant, the queue SQL predicates take the limit from the policy, and the
SettingsRepository duration helpers are deprecated delegates.

A unit test compares each historical expression with the service over a grid
of stored values; a CI step rejects new direct reads of these settings.
Every reader now belongs to a patron type (tipologie_lettore) that decides
the circulation rules, separate from the role that decides permissions.
A type sets the total active-loan limit, loan duration, longest request,
renewals, reservations and their limit, and the card duration; an empty
field inherits the global loans setting. The matrix (regole_prestito) adds
exceptions per material type: consultation only, a loan limit for that
material on top of the total, duration and renewals. Per field the first
value wins among rule (type, material), rule (type, any), rule (any,
material), patron type, global settings.

A single copy can be kept for in-house consultation: it is never allocated
nor counted as lendable capacity, and a title whose every copy is kept for
consultation is consultation only.

Staff desk, reader requests, approval, queue promotion, renewals, the
Mobile API and NCIP refuse with the same new codes (media_limit_reached,
not_loanable, reservations_disabled, reservation_limit_reached). The
settings page gets a Circulation tab, the user forms a patron type select,
the reader page the effective rules, the user list a filter and a CSV
column, the book page "Solo consultazione".

The migration seeds a default "Standard" type that inherits everything and
touches no existing user, so an install that configures nothing behaves as
before.
The patron type's reservation rules are now visible and tested end to end:
a type without reservations does not see "reserve when available" on the
book page, and the admin reservation form can override the limits with an
explicit tick.

When the patron type sets a card duration, the card expiry is computed on
creation and edit (optional, on by default when creating), on
self-registration and on registration approval. The reader page gets a
"Rinnova tessera" button: today + the type's duration (one year when it
sets none), an expired account becomes active again, the renewal is in the
activity log.

A new "card_expiring" email (five languages) goes out
loans.card_expiry_warning_days before the card expires (default 0 = off),
sent by the notifications cron once per expiry date and re-armed by a
renewal (utenti.card_expiry_warning_sent_for).
The MARC section spelled the whole enum out, so running it after a section
that had already added its own value (users) would have dropped that value.
It now appends marc to the current COLUMN_TYPE, as the patron-cards section
does, and the test proves an earlier value and its rows survive.
With the patron types schema in the tree the Tipologia column is now offered
and assigned. Only active types resolve, as in the user form; an unknown or
deactivated code keeps the default type and is reported as a warning. The
E2E imports codes in both cases, an empty one and an unknown one, and reads
the result on the reader page.
loansByPatronType() gets its built-in provider: every active type, a
deactivated one only when it has loans, readers without a type counted under
the default type. A filter on statistics.section.patron_types still wins.
A matrix cell can now set giorni_rinnovo next to duration and renewals. The
book page's renew button and its confirmation said a fixed +14 days while the
renewal adds the days of the loan's circulation rules; they now say those.
…ivated patron type from the user form

A reservation whose patron is at the limit for the title's material, or
whose patron type may not borrow it, no longer stalls the queue: promotion
and capacity occupancy both leave it aside, like a patron at the total
limit. The user form keeps refusing a deactivated patron type server-side,
as the form and the CSV import already did.
fabiodalez-dev added a commit that referenced this pull request Oct 28, 2026
The debt-block bullet described the threshold as "own type → default type
→ global"; CirculationPolicy consults the default type only for a reader
without a (valid) type, and an empty field of the type inherits the global
setting. The bullet now says that.

The Mobile API 1.9.0 changelog in plugin.json listed only /me/movements and
debt_block; it now also names the API changes of #508/#509 released under
the same version (closed_dates, loanable and reservations_enabled in the
availability payload; the 409 codes media_limit_reached, not_loanable,
reservations_disabled and reservation_limit_reached; override_limits on
the staff reservation; eligibility_reason debt_block), and the OpenAPI
descriptions tag them "Mobile API 1.9.0" instead of "Pinakes 0.9.0", the
version a client can actually check.

The French fine_charged body used "Livre:" where the subject and every
other French string write "Livre :"; the six labels are fixed in the
shipped template and in the installer seed, which stay identical.
@fabiodalez-dev
fabiodalez-dev force-pushed the feat/patron-cards-import branch from c7359fc to 92d5591 Compare October 28, 2026 17:13
@fabiodalez-dev
fabiodalez-dev force-pushed the feat/circulation-policy branch from 71a8719 to 3fc5ec3 Compare October 28, 2026 17:13
… new schema

Reassigning a loan to a copy refused as media_limit_reached or not_loanable
redirected to /admin/loans, which only had the generic update error for
them; the list now shows the same messages as the loan form. After the
migrations the Updater clears CirculationPolicy's per-connection schema
cache, so the availability recalc of that request uses the consultation
column the migration just created.
The method exists in the release that ships this Updater, and PHPStan
flags the method_exists() guard as always true.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. The loans list now explains the two new refusals, and the post-migration recalc reads a fresh schema probe.

Reviewed changes

Since the last review at 35ca0b0, there are two PR commits (6a14766, d219798) and a merge of feat/patron-cards-import (f51fcb7). The merge needed no conflict resolution, and its files belong to the base PRs.

  • Loans list explains media_limit_reached and not_loanable. Reassigning a loan in PrestitiController::update redirects to /admin/loans?error=<code> from loanLimitError(forExistingLoan(...)). prestiti/index.php now shows the same messages as crea_prestito.php for these two codes, not the generic update error. Both strings already exist in all five locales.
  • The post-migration recalc probes the schema again. Updater::recalculateAfterMigrations() now calls CirculationPolicy::resetSchemaCache() before recalculateAllBookAvailability(). A schemaReady() result cached before the migration created copie.solo_consultazione can no longer make lendableCopySql() return 1 = 1 during that recalc. d219798 removes the method_exists guard that PHPStan flagged as always true. The Updater that runs this line ships in the same release as the method. php -l passes on both files, and the locale JSONs parse.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No code issues. One merge left duplicate bullets in the CHANGELOG (comment inline).

Reviewed changes

Since the last review at d219798, the PR has had only merges of feat/patron-cards-import. This run covers the three new ones on top of 2f856c3. The base files they bring in belong to the base PRs.

  • Merged feat/patron-cards-import three times (7b863ec, dd6c348, 7977fa4). dd6c348 and 7977fa4 needed no conflict resolution. In 7b863ec the only resolved conflict is the CHANGELOG ### Migration list.
  • Checked the net PR diff. The range-diff against the last head changes only CHANGELOG.md. All five locale JSONs parse and have no duplicate keys. app/Support/UserImport.php passes php -l.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread CHANGELOG.md Resolved
@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

This change shipped in 0.9.0-rc.1: the whole stack landed in main through the release PR #514 (https://github.com/fabiodalez-dev/Pinakes/releases/tag/v0.9.0-rc.1). I'm closing this one because its base branch was part of the stack, so GitHub cannot mark it as merged on its own.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant