借书/还书/续借/逾期管理
This commit is contained in:
@@ -302,6 +302,131 @@ readers/form.jsp -> JDBC -> DELETE FROM readers using request parameters
|
||||
readers/form.jsp -> ReaderManagementServlet -> ReaderService -> ReaderDao -> readers.status = inactive
|
||||
```
|
||||
|
||||
## Scenario: Borrowing Circulation Management Slice
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: borrowing circulation now spans `borrow_records`, book inventory,
|
||||
reader eligibility, Servlet routes, service transactions, DAO locks, and JSP
|
||||
management/history screens.
|
||||
- Schema path: `src/main/resources/db/schema.sql`.
|
||||
- JSP paths: `WEB-INF/jsp/borrowing/manage.jsp`,
|
||||
`borrowing/form.jsp`, and `reader/loans.jsp`.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Entity signatures: `BorrowRecord` fields are `id`, `readerId`,
|
||||
`readerIdentifier`, `readerName`, `bookId`, `bookIdentifier`, `bookTitle`,
|
||||
`borrowedAt`, `dueAt`, nullable `returnedAt`, `renewalCount`, `status`,
|
||||
`createdAt`, and `updatedAt`.
|
||||
- Status signature: `BorrowRecordStatus` enum codes are `active` and
|
||||
`returned`; overdue is derived from active, non-returned rows where
|
||||
`due_at < CURRENT_TIMESTAMP`.
|
||||
- Search signature: `BorrowRecordSearchCriteria(readerIdentifier,
|
||||
bookIdentifier, statusCode)` where `statusCode` may be empty, `active`,
|
||||
`returned`, or the derived filter `overdue`.
|
||||
- DAO signatures: `BorrowRecordDao.search(criteria)`,
|
||||
`findByReaderId(readerId)`, `findReaderByUserId(userId)`,
|
||||
`findReaderByIdentifierForUpdate(connection, identifier)`,
|
||||
`findBookByIdentifierForUpdate(connection, identifier)`,
|
||||
`findByIdForUpdate(connection, id)`, `countActiveByReaderId(connection,
|
||||
readerId)`, `create(connection, record)`, `decrementAvailableCopies(...)`,
|
||||
`incrementAvailableCopies(...)`, `markReturned(...)`, and `renew(...)`.
|
||||
- Service signatures: `BorrowingService.searchRecords(actor, criteria)`,
|
||||
`borrowBook(actor, readerIdentifier, bookIdentifier)`,
|
||||
`returnBook(actor, recordId)`, `renewLoan(actor, recordId)`, and
|
||||
`listCurrentReaderHistory(actor)`, all returning `ServiceResult<T>`.
|
||||
- Management routes: `GET /borrowing`, `GET /borrowing/new`,
|
||||
`POST /borrowing/create`, `POST /borrowing/return`, and
|
||||
`POST /borrowing/renew`.
|
||||
- Reader route: `GET /reader/loans`.
|
||||
- Protected permissions: `/borrowing*` requires `MANAGE_BORROWING`;
|
||||
`/reader/loans` requires `BORROW_BOOKS` and the `reader` role because it
|
||||
displays only the signed-in reader's own history.
|
||||
- DB signature: `borrow_records(id, reader_id, book_id, borrowed_at, due_at,
|
||||
returned_at, renewal_count, status, created_at, updated_at)`, with foreign
|
||||
keys to `readers(id)` and `books(id)`, indexes on reader, book, status, and
|
||||
due date, and checks for non-negative renewal count and supported statuses.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Borrow operations require an active reader and a borrowable book with
|
||||
`BookStatus.AVAILABLE` and `available_copies > 0`.
|
||||
- Reader active-loan count is rows with `status = active` and `returned_at IS
|
||||
NULL`; overdue rows still count toward `max_borrow_count`.
|
||||
- Borrowing creates one `borrow_records` row and decrements
|
||||
`books.available_copies` in the same transaction.
|
||||
- Returning an active loan sets `status = returned`, stores `returned_at`, and
|
||||
increments `books.available_copies` with a cap at `total_copies` in the same
|
||||
transaction.
|
||||
- Renewing an active loan extends `due_at`, increments `renewal_count`, and is
|
||||
limited to one renewal per MVP loan.
|
||||
- `JdbcUtil.executeInTransaction` is the local transaction helper for
|
||||
multi-table borrowing workflows. Services decide the workflow boundary; DAOs
|
||||
own SQL and row-lock statements.
|
||||
- Demo `readers` and `books` seed rows must not overwrite existing rows during
|
||||
schema replay, because resetting reader eligibility or `available_copies` can
|
||||
corrupt live borrowing state.
|
||||
- Servlet controllers set JSP attributes such as `criteria`, `statuses`,
|
||||
`overdueStatus`, `maxRenewals`, `borrowRecords`, `formValues`, `errors`,
|
||||
`errorMessage`, and `successMessage`.
|
||||
- JSP pages render JavaBean properties only; they must not call DAOs or embed
|
||||
SQL.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Missing reader ID or book ID -> return to `borrowing/form.jsp` with field
|
||||
errors.
|
||||
- Unknown reader -> field error on `readerIdentifier`.
|
||||
- Inactive or suspended reader -> field error on `readerIdentifier`.
|
||||
- Reader at or above `max_borrow_count` active loans -> field error on
|
||||
`readerIdentifier`.
|
||||
- Unknown book -> field error on `bookIdentifier`.
|
||||
- Unavailable, archived, or zero-copy book -> field error on `bookIdentifier`.
|
||||
- Missing or non-positive record ID for return/renew -> flash error.
|
||||
- Returning or renewing a returned loan -> validation failure on `status`.
|
||||
- Renewing after the renewal limit -> validation failure on `renewalCount`.
|
||||
- DAO/transaction failure -> log server-side details and return
|
||||
`Borrowing service is temporarily unavailable. Please try again later.`
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: a librarian creates a loan for active reader `RD-1000` and available
|
||||
book `BK-1000`; the loan appears in `/borrowing` and book availability is
|
||||
decremented.
|
||||
- Base: `/borrowing?status=overdue` lists active, non-returned loans past due
|
||||
without requiring a scheduler or stored overdue status.
|
||||
- Bad: a Servlet updates `books.available_copies` outside the borrowing
|
||||
transaction or a JSP issues SQL to filter loan records.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Run `BorrowingServiceCheck` assertions for permission denial, inactive
|
||||
readers, unavailable/no-copy books, max active loan count, successful borrow,
|
||||
return inventory restoration, one-renewal limit, overdue search, reader loan
|
||||
history, and DAO failure fallback.
|
||||
- Run `PermissionPolicyCheck` to confirm readers have `BORROW_BOOKS`, readers
|
||||
lack `MANAGE_BORROWING`, and librarians lack reader self-borrow permission.
|
||||
Service or filter checks should also confirm staff use `/borrowing`, not the
|
||||
reader-only `/reader/loans` history route.
|
||||
- Scan JSPs for scriptlets and SQL/JDBC references.
|
||||
- When Maven/Tomcat dependencies are installed, run `mvn clean package` to
|
||||
compile Servlets and package JSP resources.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```text
|
||||
borrowing/manage.jsp -> JDBC -> UPDATE books SET available_copies = ...
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```text
|
||||
borrowing/form.jsp -> BorrowingManagementServlet -> BorrowingService -> BorrowRecordDao -> borrow_records + books in one transaction
|
||||
```
|
||||
|
||||
## Scenario: Login And Permission Scaffold Schema
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture overview for final spec compliance review."}
|
||||
{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Verify schema, DAO, transaction, and integrity handling for circulation operations."}
|
||||
{"file": ".trellis/spec/backend/error-handling.md", "reason": "Verify user-facing validation failures and exception boundaries."}
|
||||
{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Verify logging expectations for key borrowing operations and exceptions."}
|
||||
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Verify backend layer boundaries and code quality."}
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend JSP/CSS conventions for final review."}
|
||||
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Verify forms, tables, reusable fragments, and UI consistency."}
|
||||
{"file": ".trellis/spec/frontend/state-management.md", "reason": "Verify request/session/form state and validation message handling."}
|
||||
{"file": ".trellis/spec/frontend/type-safety.md", "reason": "Verify JavaBean display contracts and JSP-safe data access."}
|
||||
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Verify UI quality and responsive JSP/CSS behavior."}
|
||||
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Verify cross-layer data flow from schema through JSP."}
|
||||
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "Verify reuse of existing patterns and avoid duplicate helper logic."}
|
||||
@@ -0,0 +1,15 @@
|
||||
{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture overview and pre-development checklist for Servlet -> Service -> DAO -> MySQL work."}
|
||||
{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Required backend package and class placement conventions for new entity, DAO, service, and servlet files."}
|
||||
{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Required MySQL schema, DAO CRUD, transaction, and data-integrity conventions for borrowing records."}
|
||||
{"file": ".trellis/spec/backend/error-handling.md", "reason": "Required Servlet validation and ServiceResult-style failure handling for borrow/return/renew workflows."}
|
||||
{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Guidance for operation logging and exception tracing for circulation actions."}
|
||||
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Layer boundary and review constraints for backend implementation."}
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend JSP/CSS conventions and pre-development checklist for new circulation screens."}
|
||||
{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "Required JSP and static asset placement conventions."}
|
||||
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Existing JSP fragments, forms, tables, and reusable UI conventions."}
|
||||
{"file": ".trellis/spec/frontend/state-management.md", "reason": "Server-rendered request/session/form state conventions for validation messages and filters."}
|
||||
{"file": ".trellis/spec/frontend/type-safety.md", "reason": "JSP/Servlet validation and JavaBean display contracts."}
|
||||
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "UI quality checks for JSP/CSS changes."}
|
||||
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Borrowing spans schema, DAO, service, servlet, JSP, and permissions."}
|
||||
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "Feature should reuse existing patterns for search criteria, ServiceResult, DAOs, forms, and JSP tables."}
|
||||
{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Original project requirements include borrowing/return management in the overall library system scope."}
|
||||
@@ -0,0 +1,108 @@
|
||||
# Borrowing circulation management
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the next core library-management slice: borrowing circulation for borrow, return, renew, overdue visibility, and reader loan history.
|
||||
|
||||
## What I already know
|
||||
|
||||
* The project is a Java 11 Maven WAR application using JSP + Servlet on Tomcat and MySQL through JDBC DAO classes.
|
||||
* Existing implemented slices cover login, role/permission checks, dashboard navigation, book catalog/search, book management, and reader profile/eligibility management.
|
||||
* Current routes include `/login`, `/logout`, `/dashboard`, role homes, `/catalog`, `/books`, and `/readers`.
|
||||
* The schema already defines roles, permissions, role permissions, users, system logs, readers, book categories, and books.
|
||||
* Permissions already include `manage_borrowing` and `borrow_books`.
|
||||
* Dashboard copy mentions borrowing, return, renewal, and overdue workflows, but there are no borrowing tables, Servlet routes, JSP pages, DAO classes, services, or tests for that workflow yet.
|
||||
* The user selected borrowing circulation as the next feature slice.
|
||||
* `AuthorizationFilter` currently protects `/librarian/**` with `MANAGE_BORROWING` and `/reader/**` with `VIEW_CATALOG`.
|
||||
* `PermissionPolicy` grants `MANAGE_BORROWING` to administrators and librarians, and `BORROW_BOOKS` to readers.
|
||||
|
||||
## Scope
|
||||
|
||||
* Administrators and librarians can manage circulation records from a borrowing management screen.
|
||||
* Readers can view their own borrowing records from the reader area when their account is linked to a reader profile.
|
||||
* Borrowing workflow covers borrow, return, renew, active loans, returned loans, and overdue loans.
|
||||
* Overdue handling should be visible in lists and service results; no background scheduler is required for this task.
|
||||
* The implementation should preserve the existing Servlet -> Service -> DAO boundary and JSP view style.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Persistence
|
||||
|
||||
* Add a borrowing/loan table to `src/main/resources/db/schema.sql`.
|
||||
* Store the borrowing record id, reader id, book id, borrow date/time, due date/time, optional return date/time, renewal count, status, and timestamps.
|
||||
* Relate borrowing rows to `readers` and `books` with foreign keys.
|
||||
* Add useful indexes for reader, book, status, and due date lookups.
|
||||
* Keep seed data safe and optional; do not require destructive schema resets.
|
||||
|
||||
### Business Rules
|
||||
|
||||
* Borrowing a book requires an active reader.
|
||||
* Borrowing a book requires the book status to allow borrowing and `available_copies > 0`.
|
||||
* Borrowing must reject readers at or above `max_borrow_count` for active, non-returned loans.
|
||||
* Successful borrowing creates a borrowing record and decrements `books.available_copies`.
|
||||
* Returning an active loan marks it returned, records the return time, and increments `books.available_copies` without exceeding `total_copies`.
|
||||
* Renewing an active loan extends the due date and increments the renewal count.
|
||||
* Renewal should be limited to a small, explicit maximum, preferably one renewal per loan for this MVP.
|
||||
* Overdue records are records not returned by their due date. They should be searchable or filterable.
|
||||
* Service methods should return `ServiceResult` style validation errors instead of throwing user-facing exceptions for normal validation failures.
|
||||
|
||||
### Backend
|
||||
|
||||
* Add entity, DAO, JDBC DAO, service, and service implementation classes for borrowing circulation.
|
||||
* Add Servlet routes for borrowing management under an administrator/librarian-accessible path such as `/borrowing`, `/borrowing/new`, `/borrowing/create`, `/borrowing/return`, and `/borrowing/renew`.
|
||||
* Add a reader-facing route for own borrowing records under the existing reader area or another path protected appropriately.
|
||||
* Update `web.xml` and authorization rules as needed.
|
||||
* Keep transaction-sensitive borrow/return/renew operations consistent. If the existing JDBC helper does not provide transactions, implement the smallest safe local transaction pattern needed for multi-table updates.
|
||||
|
||||
### Frontend
|
||||
|
||||
* Add JSP pages for borrowing list/history and the borrow form.
|
||||
* Add navigation entries from dashboard, role home, and header where appropriate.
|
||||
* Keep the UI consistent with existing card, table, form, alert, and button patterns.
|
||||
* Show clear success and validation messages for borrow, return, and renew actions.
|
||||
* Lists should show reader identifier/name, book identifier/title, borrow date, due date, return date when present, renewal count, and status.
|
||||
|
||||
### Tests
|
||||
|
||||
* Add or update service-level check classes for borrowing rules.
|
||||
* Update permission tests if new path/permission logic changes.
|
||||
* Build with Maven after implementation if Maven is available.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
* [ ] Administrator/librarian can open a borrowing management page.
|
||||
* [ ] Administrator/librarian can create a valid borrowing record by selecting or entering an existing active reader and borrowable book.
|
||||
* [ ] Borrowing decrements available copies and blocks unavailable books.
|
||||
* [ ] Borrowing blocks inactive/suspended readers and readers over their max active borrowing count.
|
||||
* [ ] Administrator/librarian can return an active loan; the book copy count is restored.
|
||||
* [ ] Administrator/librarian can renew an active loan within the configured renewal limit.
|
||||
* [ ] Borrowing lists can distinguish active, returned, and overdue records.
|
||||
* [ ] Reader can view their own borrowing records when logged in with a linked reader profile.
|
||||
* [ ] Relevant service checks pass.
|
||||
* [ ] Maven build/check commands pass where available.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
* Tests added/updated where appropriate.
|
||||
* Lint/typecheck/build checks are green.
|
||||
* Docs/notes updated if behavior changes.
|
||||
* Rollback considered if risky.
|
||||
|
||||
## Out of Scope (explicit)
|
||||
|
||||
* Reader self-service borrow requests/reservations are not part of this MVP.
|
||||
* Email/SMS notifications are out of scope.
|
||||
* Fine calculation/payment is out of scope.
|
||||
* Background jobs/schedulers are out of scope.
|
||||
* Full reports/rankings remain out of scope.
|
||||
* No unrelated refactors or visual redesigns.
|
||||
|
||||
## Technical Notes
|
||||
|
||||
* `README.md` confirms implemented scaffold slices and project stack.
|
||||
* `src/main/webapp/WEB-INF/web.xml` defines current Servlet mappings.
|
||||
* `src/main/resources/db/schema.sql` has no borrowing/loan table yet.
|
||||
* `src/main/webapp/WEB-INF/jsp/dashboard.jsp` and `role-home.jsp` already refer to borrowing workflows in UI copy.
|
||||
* `src/main/java/com/mzh/library/entity/Permission.java` already defines `MANAGE_BORROWING` and `BORROW_BOOKS`.
|
||||
* `src/main/java/com/mzh/library/filter/AuthorizationFilter.java` may need a `/borrowing` rule mapped to `MANAGE_BORROWING` and reader borrowing history should not expose other readers' records.
|
||||
* The local `codebase-retrieval` MCP call was rejected by the approval layer, so context was gathered from targeted repo file reads instead.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "continue-development",
|
||||
"name": "continue-development",
|
||||
"title": "brainstorm: 继续开发程序",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "Zzzz",
|
||||
"assignee": "Zzzz",
|
||||
"createdAt": "2026-04-27",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "master",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.mzh.library.controller;
|
||||
|
||||
import com.mzh.library.dao.impl.JdbcBorrowRecordDao;
|
||||
import com.mzh.library.entity.AuthenticatedUser;
|
||||
import com.mzh.library.entity.BorrowRecord;
|
||||
import com.mzh.library.entity.BorrowRecordSearchCriteria;
|
||||
import com.mzh.library.entity.BorrowRecordStatus;
|
||||
import com.mzh.library.service.ServiceResult;
|
||||
import com.mzh.library.service.impl.BorrowingServiceImpl;
|
||||
import com.mzh.library.util.SessionAttributes;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
public class BorrowingManagementServlet extends HttpServlet {
|
||||
private static final String MANAGE_JSP = "/WEB-INF/jsp/borrowing/manage.jsp";
|
||||
private static final String FORM_JSP = "/WEB-INF/jsp/borrowing/form.jsp";
|
||||
private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
|
||||
private static final String FLASH_SUCCESS = "flashSuccess";
|
||||
private static final String FLASH_ERROR = "flashError";
|
||||
|
||||
private BorrowingServiceImpl borrowingService;
|
||||
|
||||
@Override
|
||||
public void init() {
|
||||
this.borrowingService = new BorrowingServiceImpl(new JdbcBorrowRecordDao());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
|
||||
String path = request.getServletPath();
|
||||
if ("/borrowing/new".equals(path)) {
|
||||
renderForm(request, response, Collections.emptyMap(), Collections.emptyMap(), null);
|
||||
return;
|
||||
}
|
||||
if (!"/borrowing".equals(path)) {
|
||||
response.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
|
||||
showManagementList(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
|
||||
String path = request.getServletPath();
|
||||
if ("/borrowing/create".equals(path)) {
|
||||
createBorrowRecord(request, response);
|
||||
return;
|
||||
}
|
||||
if ("/borrowing/return".equals(path)) {
|
||||
returnBook(request, response);
|
||||
return;
|
||||
}
|
||||
if ("/borrowing/renew".equals(path)) {
|
||||
renewLoan(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
response.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
}
|
||||
|
||||
private void showManagementList(HttpServletRequest request, HttpServletResponse response)
|
||||
throws ServletException, IOException {
|
||||
BorrowRecordSearchCriteria criteria = searchCriteria(request);
|
||||
request.setAttribute("criteria", criteria);
|
||||
request.setAttribute("statuses", BorrowRecordStatus.values());
|
||||
request.setAttribute("overdueStatus", BorrowRecordSearchCriteria.OVERDUE_STATUS);
|
||||
request.setAttribute("maxRenewals", borrowingService.getMaxRenewals());
|
||||
applyFlash(request);
|
||||
|
||||
ServiceResult<List<BorrowRecord>> result = borrowingService.searchRecords(currentUser(request), criteria);
|
||||
if (isPermissionDenied(result)) {
|
||||
forwardDenied(request, response, result.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
request.setAttribute("borrowRecords", result.isSuccessful() ? result.getData() : Collections.emptyList());
|
||||
if (!result.isSuccessful()) {
|
||||
request.setAttribute("errorMessage", result.getMessage());
|
||||
request.setAttribute("errors", result.getErrors());
|
||||
}
|
||||
|
||||
request.getRequestDispatcher(MANAGE_JSP).forward(request, response);
|
||||
}
|
||||
|
||||
private void createBorrowRecord(HttpServletRequest request, HttpServletResponse response)
|
||||
throws ServletException, IOException {
|
||||
Map<String, String> values = formValues(request);
|
||||
ServiceResult<Long> result = borrowingService.borrowBook(currentUser(request),
|
||||
values.get("readerIdentifier"), values.get("bookIdentifier"));
|
||||
if (isPermissionDenied(result)) {
|
||||
forwardDenied(request, response, result.getMessage());
|
||||
return;
|
||||
}
|
||||
if (!result.isSuccessful()) {
|
||||
renderForm(request, response, values, result.getErrors(), result.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
flashSuccess(request, result.getMessage());
|
||||
response.sendRedirect(request.getContextPath() + "/borrowing");
|
||||
}
|
||||
|
||||
private void returnBook(HttpServletRequest request, HttpServletResponse response)
|
||||
throws IOException, ServletException {
|
||||
long id = requiredLong(request.getParameter("id"), -1L);
|
||||
ServiceResult<Void> result = id <= 0
|
||||
? ServiceResult.failure("Select a valid borrowing record.")
|
||||
: borrowingService.returnBook(currentUser(request), id);
|
||||
redirectWithResult(request, response, result);
|
||||
}
|
||||
|
||||
private void renewLoan(HttpServletRequest request, HttpServletResponse response)
|
||||
throws IOException, ServletException {
|
||||
long id = requiredLong(request.getParameter("id"), -1L);
|
||||
ServiceResult<Void> result = id <= 0
|
||||
? ServiceResult.failure("Select a valid borrowing record.")
|
||||
: borrowingService.renewLoan(currentUser(request), id);
|
||||
redirectWithResult(request, response, result);
|
||||
}
|
||||
|
||||
private void redirectWithResult(HttpServletRequest request, HttpServletResponse response, ServiceResult<?> result)
|
||||
throws IOException, ServletException {
|
||||
if (isPermissionDenied(result)) {
|
||||
forwardDenied(request, response, result.getMessage());
|
||||
return;
|
||||
}
|
||||
if (result.isSuccessful()) {
|
||||
flashSuccess(request, result.getMessage());
|
||||
} else {
|
||||
flashError(request, messageFor(result));
|
||||
}
|
||||
response.sendRedirect(request.getContextPath() + "/borrowing");
|
||||
}
|
||||
|
||||
private void renderForm(HttpServletRequest request, HttpServletResponse response, Map<String, String> formValues,
|
||||
Map<String, String> errors, String errorMessage)
|
||||
throws ServletException, IOException {
|
||||
request.setAttribute("formValues", formValues);
|
||||
request.setAttribute("errors", errors);
|
||||
if (errorMessage != null && !errorMessage.isEmpty()) {
|
||||
request.setAttribute("errorMessage", errorMessage);
|
||||
}
|
||||
request.getRequestDispatcher(FORM_JSP).forward(request, response);
|
||||
}
|
||||
|
||||
private BorrowRecordSearchCriteria searchCriteria(HttpServletRequest request) {
|
||||
return new BorrowRecordSearchCriteria(
|
||||
request.getParameter("readerIdentifier"),
|
||||
request.getParameter("bookIdentifier"),
|
||||
request.getParameter("status")
|
||||
);
|
||||
}
|
||||
|
||||
private Map<String, String> formValues(HttpServletRequest request) {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
values.put("readerIdentifier", trim(request.getParameter("readerIdentifier")));
|
||||
values.put("bookIdentifier", trim(request.getParameter("bookIdentifier")));
|
||||
return values;
|
||||
}
|
||||
|
||||
private long requiredLong(String value, long fallback) {
|
||||
try {
|
||||
long parsed = Long.parseLong(trim(value));
|
||||
return parsed > 0 ? parsed : fallback;
|
||||
} catch (NumberFormatException ex) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private String messageFor(ServiceResult<?> result) {
|
||||
if (result.getMessage() != null && !result.getMessage().isEmpty()) {
|
||||
return result.getMessage();
|
||||
}
|
||||
if (result.hasErrors()) {
|
||||
return result.getErrors().values().iterator().next();
|
||||
}
|
||||
return "Borrowing action failed.";
|
||||
}
|
||||
|
||||
private boolean isPermissionDenied(ServiceResult<?> result) {
|
||||
return !result.isSuccessful()
|
||||
&& "You do not have permission to manage borrowing.".equals(result.getMessage());
|
||||
}
|
||||
|
||||
private void forwardDenied(HttpServletRequest request, HttpServletResponse response, String message)
|
||||
throws ServletException, IOException {
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
request.setAttribute("errorMessage", message);
|
||||
request.getRequestDispatcher(UNAUTHORIZED_JSP).forward(request, response);
|
||||
}
|
||||
|
||||
private AuthenticatedUser currentUser(HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(false);
|
||||
Object value = session == null ? null : session.getAttribute(SessionAttributes.AUTHENTICATED_USER);
|
||||
return value instanceof AuthenticatedUser ? (AuthenticatedUser) value : null;
|
||||
}
|
||||
|
||||
private void applyFlash(HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
moveFlash(session, request, FLASH_SUCCESS, "successMessage");
|
||||
moveFlash(session, request, FLASH_ERROR, "errorMessage");
|
||||
}
|
||||
|
||||
private void moveFlash(HttpSession session, HttpServletRequest request, String sessionKey, String requestKey) {
|
||||
Object value = session.getAttribute(sessionKey);
|
||||
if (value != null) {
|
||||
request.setAttribute(requestKey, value);
|
||||
session.removeAttribute(sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void flashSuccess(HttpServletRequest request, String message) {
|
||||
request.getSession().setAttribute(FLASH_SUCCESS, message);
|
||||
}
|
||||
|
||||
private void flashError(HttpServletRequest request, String message) {
|
||||
request.getSession().setAttribute(FLASH_ERROR, message);
|
||||
}
|
||||
|
||||
private String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.mzh.library.controller;
|
||||
|
||||
import com.mzh.library.dao.impl.JdbcBorrowRecordDao;
|
||||
import com.mzh.library.entity.AuthenticatedUser;
|
||||
import com.mzh.library.entity.BorrowRecord;
|
||||
import com.mzh.library.service.ServiceResult;
|
||||
import com.mzh.library.service.impl.BorrowingServiceImpl;
|
||||
import com.mzh.library.util.SessionAttributes;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
public class ReaderLoanHistoryServlet extends HttpServlet {
|
||||
private static final String HISTORY_JSP = "/WEB-INF/jsp/reader/loans.jsp";
|
||||
private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
|
||||
private static final String HISTORY_DENIED_MESSAGE = "You do not have permission to view loan history.";
|
||||
|
||||
private BorrowingServiceImpl borrowingService;
|
||||
|
||||
@Override
|
||||
public void init() {
|
||||
this.borrowingService = new BorrowingServiceImpl(new JdbcBorrowRecordDao());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
|
||||
ServiceResult<List<BorrowRecord>> result = borrowingService.listCurrentReaderHistory(currentUser(request));
|
||||
if (isPermissionDenied(result)) {
|
||||
forwardDenied(request, response, result.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
request.setAttribute("borrowRecords", result.isSuccessful() ? result.getData() : Collections.emptyList());
|
||||
if (result.isSuccessful() && result.getMessage() != null && !result.getMessage().isEmpty()) {
|
||||
request.setAttribute("successMessage", result.getMessage());
|
||||
}
|
||||
if (!result.isSuccessful()) {
|
||||
request.setAttribute("errorMessage", result.getMessage());
|
||||
}
|
||||
|
||||
request.getRequestDispatcher(HISTORY_JSP).forward(request, response);
|
||||
}
|
||||
|
||||
private boolean isPermissionDenied(ServiceResult<?> result) {
|
||||
return !result.isSuccessful() && HISTORY_DENIED_MESSAGE.equals(result.getMessage());
|
||||
}
|
||||
|
||||
private void forwardDenied(HttpServletRequest request, HttpServletResponse response, String message)
|
||||
throws ServletException, IOException {
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
request.setAttribute("errorMessage", message);
|
||||
request.getRequestDispatcher(UNAUTHORIZED_JSP).forward(request, response);
|
||||
}
|
||||
|
||||
private AuthenticatedUser currentUser(HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(false);
|
||||
Object value = session == null ? null : session.getAttribute(SessionAttributes.AUTHENTICATED_USER);
|
||||
return value instanceof AuthenticatedUser ? (AuthenticatedUser) value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.mzh.library.dao;
|
||||
|
||||
import com.mzh.library.entity.Book;
|
||||
import com.mzh.library.entity.BorrowRecord;
|
||||
import com.mzh.library.entity.BorrowRecordSearchCriteria;
|
||||
import com.mzh.library.entity.Reader;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface BorrowRecordDao {
|
||||
List<BorrowRecord> search(BorrowRecordSearchCriteria criteria);
|
||||
|
||||
List<BorrowRecord> findByReaderId(long readerId);
|
||||
|
||||
Optional<Reader> findReaderByUserId(long userId);
|
||||
|
||||
Optional<Reader> findReaderByIdentifierForUpdate(Connection connection, String identifier);
|
||||
|
||||
Optional<Book> findBookByIdentifierForUpdate(Connection connection, String identifier);
|
||||
|
||||
Optional<BorrowRecord> findByIdForUpdate(Connection connection, long id);
|
||||
|
||||
int countActiveByReaderId(Connection connection, long readerId);
|
||||
|
||||
long create(Connection connection, BorrowRecord record);
|
||||
|
||||
boolean decrementAvailableCopies(Connection connection, long bookId);
|
||||
|
||||
boolean incrementAvailableCopies(Connection connection, long bookId);
|
||||
|
||||
boolean markReturned(Connection connection, long id, LocalDateTime returnedAt);
|
||||
|
||||
boolean renew(Connection connection, long id, LocalDateTime dueAt);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package com.mzh.library.dao.impl;
|
||||
|
||||
import com.mzh.library.dao.BorrowRecordDao;
|
||||
import com.mzh.library.entity.Book;
|
||||
import com.mzh.library.entity.BookStatus;
|
||||
import com.mzh.library.entity.BorrowRecord;
|
||||
import com.mzh.library.entity.BorrowRecordSearchCriteria;
|
||||
import com.mzh.library.entity.BorrowRecordStatus;
|
||||
import com.mzh.library.entity.Reader;
|
||||
import com.mzh.library.entity.ReaderStatus;
|
||||
import com.mzh.library.exception.DaoException;
|
||||
import com.mzh.library.util.JdbcUtil;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class JdbcBorrowRecordDao implements BorrowRecordDao {
|
||||
private static final String RECORD_COLUMNS = ""
|
||||
+ "br.id, br.reader_id, r.reader_identifier, r.full_name AS reader_name, "
|
||||
+ "br.book_id, b.book_identifier, b.title AS book_title, "
|
||||
+ "br.borrowed_at, br.due_at, br.returned_at, br.renewal_count, br.status, "
|
||||
+ "br.created_at, br.updated_at ";
|
||||
|
||||
private static final String RECORD_FROM = ""
|
||||
+ "FROM borrow_records br "
|
||||
+ "JOIN readers r ON r.id = br.reader_id "
|
||||
+ "JOIN books b ON b.id = br.book_id ";
|
||||
|
||||
private static final String READER_COLUMNS = ""
|
||||
+ "r.id, r.reader_identifier, r.user_id, u.username, r.full_name, r.phone, r.email, "
|
||||
+ "r.status, r.max_borrow_count, r.created_at, r.updated_at ";
|
||||
|
||||
private static final String READER_FROM = ""
|
||||
+ "FROM readers r "
|
||||
+ "LEFT JOIN users u ON u.id = r.user_id ";
|
||||
|
||||
private static final String BOOK_COLUMNS = ""
|
||||
+ "b.id, b.book_identifier, b.title, b.author, b.category_id, c.name AS category_name, "
|
||||
+ "b.total_copies, b.available_copies, b.status, b.created_at, b.updated_at ";
|
||||
|
||||
private static final String BOOK_FROM = ""
|
||||
+ "FROM books b "
|
||||
+ "JOIN book_categories c ON c.id = b.category_id ";
|
||||
|
||||
private static final String FIND_READER_BY_USER_ID = "SELECT " + READER_COLUMNS + READER_FROM
|
||||
+ "WHERE r.user_id = ?";
|
||||
|
||||
private static final String FIND_READER_BY_IDENTIFIER_FOR_UPDATE = "SELECT " + READER_COLUMNS + READER_FROM
|
||||
+ "WHERE r.reader_identifier = ? FOR UPDATE";
|
||||
|
||||
private static final String FIND_BOOK_BY_IDENTIFIER_FOR_UPDATE = "SELECT " + BOOK_COLUMNS + BOOK_FROM
|
||||
+ "WHERE b.book_identifier = ? FOR UPDATE";
|
||||
|
||||
private static final String FIND_RECORD_BY_ID_FOR_UPDATE = "SELECT " + RECORD_COLUMNS + RECORD_FROM
|
||||
+ "WHERE br.id = ? FOR UPDATE";
|
||||
|
||||
private static final String COUNT_ACTIVE_BY_READER_ID = ""
|
||||
+ "SELECT COUNT(*) "
|
||||
+ "FROM borrow_records "
|
||||
+ "WHERE reader_id = ? AND status = ? AND returned_at IS NULL";
|
||||
|
||||
private static final String CREATE = ""
|
||||
+ "INSERT INTO borrow_records "
|
||||
+ "(reader_id, book_id, borrowed_at, due_at, renewal_count, status) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, ?)";
|
||||
|
||||
private static final String DECREMENT_AVAILABLE = ""
|
||||
+ "UPDATE books "
|
||||
+ "SET available_copies = available_copies - 1 "
|
||||
+ "WHERE id = ? AND available_copies > 0";
|
||||
|
||||
private static final String INCREMENT_AVAILABLE = ""
|
||||
+ "UPDATE books "
|
||||
+ "SET available_copies = LEAST(available_copies + 1, total_copies) "
|
||||
+ "WHERE id = ?";
|
||||
|
||||
private static final String MARK_RETURNED = ""
|
||||
+ "UPDATE borrow_records "
|
||||
+ "SET status = ?, returned_at = ? "
|
||||
+ "WHERE id = ? AND status = ? AND returned_at IS NULL";
|
||||
|
||||
private static final String RENEW = ""
|
||||
+ "UPDATE borrow_records "
|
||||
+ "SET due_at = ?, renewal_count = renewal_count + 1 "
|
||||
+ "WHERE id = ? AND status = ? AND returned_at IS NULL";
|
||||
|
||||
@Override
|
||||
public List<BorrowRecord> search(BorrowRecordSearchCriteria criteria) {
|
||||
BorrowRecordSearchCriteria normalized = criteria == null ? new BorrowRecordSearchCriteria() : criteria;
|
||||
List<Object> parameters = new ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder("SELECT ")
|
||||
.append(RECORD_COLUMNS)
|
||||
.append(RECORD_FROM)
|
||||
.append("WHERE 1 = 1 ");
|
||||
|
||||
appendLike(sql, parameters, "r.reader_identifier", normalized.getReaderIdentifier());
|
||||
appendLike(sql, parameters, "b.book_identifier", normalized.getBookIdentifier());
|
||||
appendStatus(sql, parameters, normalized);
|
||||
appendOrder(sql);
|
||||
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql.toString())) {
|
||||
bind(statement, parameters);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return mapRecords(resultSet);
|
||||
}
|
||||
} catch (SQLException | IllegalArgumentException ex) {
|
||||
throw new DaoException("Unable to search borrow records", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BorrowRecord> findByReaderId(long readerId) {
|
||||
StringBuilder sql = new StringBuilder("SELECT ")
|
||||
.append(RECORD_COLUMNS)
|
||||
.append(RECORD_FROM)
|
||||
.append("WHERE br.reader_id = ? ");
|
||||
appendOrder(sql);
|
||||
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql.toString())) {
|
||||
statement.setLong(1, readerId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return mapRecords(resultSet);
|
||||
}
|
||||
} catch (SQLException | IllegalArgumentException ex) {
|
||||
throw new DaoException("Unable to load reader borrow records", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findReaderByUserId(long userId) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(FIND_READER_BY_USER_ID)) {
|
||||
statement.setLong(1, userId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? Optional.of(mapReader(resultSet)) : Optional.empty();
|
||||
}
|
||||
} catch (SQLException | IllegalArgumentException ex) {
|
||||
throw new DaoException("Unable to load reader by user id for borrowing", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findReaderByIdentifierForUpdate(Connection connection, String identifier) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(FIND_READER_BY_IDENTIFIER_FOR_UPDATE)) {
|
||||
statement.setString(1, identifier);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? Optional.of(mapReader(resultSet)) : Optional.empty();
|
||||
}
|
||||
} catch (SQLException | IllegalArgumentException ex) {
|
||||
throw new DaoException("Unable to lock reader for borrowing", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Book> findBookByIdentifierForUpdate(Connection connection, String identifier) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(FIND_BOOK_BY_IDENTIFIER_FOR_UPDATE)) {
|
||||
statement.setString(1, identifier);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? Optional.of(mapBook(resultSet)) : Optional.empty();
|
||||
}
|
||||
} catch (SQLException | IllegalArgumentException ex) {
|
||||
throw new DaoException("Unable to lock book for borrowing", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BorrowRecord> findByIdForUpdate(Connection connection, long id) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(FIND_RECORD_BY_ID_FOR_UPDATE)) {
|
||||
statement.setLong(1, id);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? Optional.of(mapRecord(resultSet)) : Optional.empty();
|
||||
}
|
||||
} catch (SQLException | IllegalArgumentException ex) {
|
||||
throw new DaoException("Unable to lock borrow record", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countActiveByReaderId(Connection connection, long readerId) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(COUNT_ACTIVE_BY_READER_ID)) {
|
||||
statement.setLong(1, readerId);
|
||||
statement.setString(2, BorrowRecordStatus.ACTIVE.getCode());
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? resultSet.getInt(1) : 0;
|
||||
}
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to count active borrow records", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long create(Connection connection, BorrowRecord record) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(CREATE, Statement.RETURN_GENERATED_KEYS)) {
|
||||
statement.setLong(1, record.getReaderId());
|
||||
statement.setLong(2, record.getBookId());
|
||||
statement.setTimestamp(3, Timestamp.valueOf(record.getBorrowedAt()));
|
||||
statement.setTimestamp(4, Timestamp.valueOf(record.getDueAt()));
|
||||
statement.setInt(5, record.getRenewalCount());
|
||||
statement.setString(6, record.getStatus().getCode());
|
||||
statement.executeUpdate();
|
||||
|
||||
try (ResultSet generatedKeys = statement.getGeneratedKeys()) {
|
||||
if (generatedKeys.next()) {
|
||||
return generatedKeys.getLong(1);
|
||||
}
|
||||
}
|
||||
throw new DaoException("Unable to read generated borrow record id", null);
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to create borrow record", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean decrementAvailableCopies(Connection connection, long bookId) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(DECREMENT_AVAILABLE)) {
|
||||
statement.setLong(1, bookId);
|
||||
return statement.executeUpdate() == 1;
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to decrement available book copies", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean incrementAvailableCopies(Connection connection, long bookId) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(INCREMENT_AVAILABLE)) {
|
||||
statement.setLong(1, bookId);
|
||||
statement.executeUpdate();
|
||||
return true;
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to increment available book copies", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean markReturned(Connection connection, long id, LocalDateTime returnedAt) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(MARK_RETURNED)) {
|
||||
statement.setString(1, BorrowRecordStatus.RETURNED.getCode());
|
||||
statement.setTimestamp(2, Timestamp.valueOf(returnedAt));
|
||||
statement.setLong(3, id);
|
||||
statement.setString(4, BorrowRecordStatus.ACTIVE.getCode());
|
||||
return statement.executeUpdate() == 1;
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to mark borrow record returned", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean renew(Connection connection, long id, LocalDateTime dueAt) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(RENEW)) {
|
||||
statement.setTimestamp(1, Timestamp.valueOf(dueAt));
|
||||
statement.setLong(2, id);
|
||||
statement.setString(3, BorrowRecordStatus.ACTIVE.getCode());
|
||||
return statement.executeUpdate() == 1;
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to renew borrow record", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendLike(StringBuilder sql, List<Object> parameters, String column, String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
sql.append("AND ").append(column).append(" LIKE ? ");
|
||||
parameters.add("%" + value.trim() + "%");
|
||||
}
|
||||
|
||||
private void appendStatus(StringBuilder sql, List<Object> parameters, BorrowRecordSearchCriteria criteria) {
|
||||
String statusCode = criteria.getStatusCode();
|
||||
if (statusCode == null || statusCode.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (criteria.isOverdueOnly()) {
|
||||
sql.append("AND br.status = ? AND br.returned_at IS NULL AND br.due_at < CURRENT_TIMESTAMP ");
|
||||
parameters.add(BorrowRecordStatus.ACTIVE.getCode());
|
||||
return;
|
||||
}
|
||||
sql.append("AND br.status = ? ");
|
||||
parameters.add(statusCode);
|
||||
}
|
||||
|
||||
private void appendOrder(StringBuilder sql) {
|
||||
sql.append("ORDER BY ")
|
||||
.append("CASE ")
|
||||
.append("WHEN br.status = 'active' AND br.returned_at IS NULL AND br.due_at < CURRENT_TIMESTAMP THEN 0 ")
|
||||
.append("WHEN br.status = 'active' THEN 1 ELSE 2 END, ")
|
||||
.append("br.due_at, br.borrowed_at DESC");
|
||||
}
|
||||
|
||||
private void bind(PreparedStatement statement, List<Object> parameters) throws SQLException {
|
||||
for (int i = 0; i < parameters.size(); i++) {
|
||||
statement.setString(i + 1, parameters.get(i).toString());
|
||||
}
|
||||
}
|
||||
|
||||
private List<BorrowRecord> mapRecords(ResultSet resultSet) throws SQLException {
|
||||
List<BorrowRecord> records = new ArrayList<>();
|
||||
while (resultSet.next()) {
|
||||
records.add(mapRecord(resultSet));
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
private BorrowRecord mapRecord(ResultSet resultSet) throws SQLException {
|
||||
BorrowRecord record = new BorrowRecord();
|
||||
record.setId(resultSet.getLong("id"));
|
||||
record.setReaderId(resultSet.getLong("reader_id"));
|
||||
record.setReaderIdentifier(resultSet.getString("reader_identifier"));
|
||||
record.setReaderName(resultSet.getString("reader_name"));
|
||||
record.setBookId(resultSet.getLong("book_id"));
|
||||
record.setBookIdentifier(resultSet.getString("book_identifier"));
|
||||
record.setBookTitle(resultSet.getString("book_title"));
|
||||
record.setBorrowedAt(toLocalDateTime(resultSet.getTimestamp("borrowed_at")));
|
||||
record.setDueAt(toLocalDateTime(resultSet.getTimestamp("due_at")));
|
||||
record.setReturnedAt(toLocalDateTime(resultSet.getTimestamp("returned_at")));
|
||||
record.setRenewalCount(resultSet.getInt("renewal_count"));
|
||||
record.setStatus(BorrowRecordStatus.fromCode(resultSet.getString("status")));
|
||||
record.setCreatedAt(toLocalDateTime(resultSet.getTimestamp("created_at")));
|
||||
record.setUpdatedAt(toLocalDateTime(resultSet.getTimestamp("updated_at")));
|
||||
return record;
|
||||
}
|
||||
|
||||
private Reader mapReader(ResultSet resultSet) throws SQLException {
|
||||
Reader reader = new Reader();
|
||||
reader.setId(resultSet.getLong("id"));
|
||||
reader.setIdentifier(resultSet.getString("reader_identifier"));
|
||||
long userId = resultSet.getLong("user_id");
|
||||
reader.setUserId(resultSet.wasNull() ? null : userId);
|
||||
reader.setUsername(resultSet.getString("username"));
|
||||
reader.setFullName(resultSet.getString("full_name"));
|
||||
reader.setPhone(resultSet.getString("phone"));
|
||||
reader.setEmail(resultSet.getString("email"));
|
||||
reader.setStatus(ReaderStatus.fromCode(resultSet.getString("status")));
|
||||
reader.setMaxBorrowCount(resultSet.getInt("max_borrow_count"));
|
||||
reader.setCreatedAt(toLocalDateTime(resultSet.getTimestamp("created_at")));
|
||||
reader.setUpdatedAt(toLocalDateTime(resultSet.getTimestamp("updated_at")));
|
||||
return reader;
|
||||
}
|
||||
|
||||
private Book mapBook(ResultSet resultSet) throws SQLException {
|
||||
Book book = new Book();
|
||||
book.setId(resultSet.getLong("id"));
|
||||
book.setIdentifier(resultSet.getString("book_identifier"));
|
||||
book.setTitle(resultSet.getString("title"));
|
||||
book.setAuthor(resultSet.getString("author"));
|
||||
book.setCategoryId(resultSet.getLong("category_id"));
|
||||
book.setCategoryName(resultSet.getString("category_name"));
|
||||
book.setTotalCopies(resultSet.getInt("total_copies"));
|
||||
book.setAvailableCopies(resultSet.getInt("available_copies"));
|
||||
book.setStatus(BookStatus.fromCode(resultSet.getString("status")));
|
||||
book.setCreatedAt(toLocalDateTime(resultSet.getTimestamp("created_at")));
|
||||
book.setUpdatedAt(toLocalDateTime(resultSet.getTimestamp("updated_at")));
|
||||
return book;
|
||||
}
|
||||
|
||||
private LocalDateTime toLocalDateTime(Timestamp timestamp) {
|
||||
return timestamp == null ? null : timestamp.toLocalDateTime();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.mzh.library.entity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
public class BorrowRecord {
|
||||
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
|
||||
private long id;
|
||||
private long readerId;
|
||||
private String readerIdentifier;
|
||||
private String readerName;
|
||||
private long bookId;
|
||||
private String bookIdentifier;
|
||||
private String bookTitle;
|
||||
private LocalDateTime borrowedAt;
|
||||
private LocalDateTime dueAt;
|
||||
private LocalDateTime returnedAt;
|
||||
private int renewalCount;
|
||||
private BorrowRecordStatus status;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public long getReaderId() {
|
||||
return readerId;
|
||||
}
|
||||
|
||||
public void setReaderId(long readerId) {
|
||||
this.readerId = readerId;
|
||||
}
|
||||
|
||||
public String getReaderIdentifier() {
|
||||
return readerIdentifier;
|
||||
}
|
||||
|
||||
public void setReaderIdentifier(String readerIdentifier) {
|
||||
this.readerIdentifier = readerIdentifier;
|
||||
}
|
||||
|
||||
public String getReaderName() {
|
||||
return readerName;
|
||||
}
|
||||
|
||||
public void setReaderName(String readerName) {
|
||||
this.readerName = readerName;
|
||||
}
|
||||
|
||||
public long getBookId() {
|
||||
return bookId;
|
||||
}
|
||||
|
||||
public void setBookId(long bookId) {
|
||||
this.bookId = bookId;
|
||||
}
|
||||
|
||||
public String getBookIdentifier() {
|
||||
return bookIdentifier;
|
||||
}
|
||||
|
||||
public void setBookIdentifier(String bookIdentifier) {
|
||||
this.bookIdentifier = bookIdentifier;
|
||||
}
|
||||
|
||||
public String getBookTitle() {
|
||||
return bookTitle;
|
||||
}
|
||||
|
||||
public void setBookTitle(String bookTitle) {
|
||||
this.bookTitle = bookTitle;
|
||||
}
|
||||
|
||||
public LocalDateTime getBorrowedAt() {
|
||||
return borrowedAt;
|
||||
}
|
||||
|
||||
public void setBorrowedAt(LocalDateTime borrowedAt) {
|
||||
this.borrowedAt = borrowedAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getDueAt() {
|
||||
return dueAt;
|
||||
}
|
||||
|
||||
public void setDueAt(LocalDateTime dueAt) {
|
||||
this.dueAt = dueAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getReturnedAt() {
|
||||
return returnedAt;
|
||||
}
|
||||
|
||||
public void setReturnedAt(LocalDateTime returnedAt) {
|
||||
this.returnedAt = returnedAt;
|
||||
}
|
||||
|
||||
public int getRenewalCount() {
|
||||
return renewalCount;
|
||||
}
|
||||
|
||||
public void setRenewalCount(int renewalCount) {
|
||||
this.renewalCount = renewalCount;
|
||||
}
|
||||
|
||||
public BorrowRecordStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(BorrowRecordStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public boolean isOverdue() {
|
||||
return BorrowRecordStatus.ACTIVE == status
|
||||
&& returnedAt == null
|
||||
&& dueAt != null
|
||||
&& dueAt.isBefore(LocalDateTime.now());
|
||||
}
|
||||
|
||||
public String getDisplayStatusCode() {
|
||||
return isOverdue() ? BorrowRecordSearchCriteria.OVERDUE_STATUS : status.getCode();
|
||||
}
|
||||
|
||||
public String getDisplayStatusName() {
|
||||
return isOverdue() ? "Overdue" : status.getDisplayName();
|
||||
}
|
||||
|
||||
public String getBorrowedAtText() {
|
||||
return format(borrowedAt);
|
||||
}
|
||||
|
||||
public String getDueAtText() {
|
||||
return format(dueAt);
|
||||
}
|
||||
|
||||
public String getReturnedAtText() {
|
||||
return format(returnedAt);
|
||||
}
|
||||
|
||||
private String format(LocalDateTime value) {
|
||||
return value == null ? "" : DISPLAY_FORMAT.format(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.mzh.library.entity;
|
||||
|
||||
public class BorrowRecordSearchCriteria {
|
||||
public static final String OVERDUE_STATUS = "overdue";
|
||||
|
||||
private String readerIdentifier;
|
||||
private String bookIdentifier;
|
||||
private String statusCode;
|
||||
|
||||
public BorrowRecordSearchCriteria() {
|
||||
}
|
||||
|
||||
public BorrowRecordSearchCriteria(String readerIdentifier, String bookIdentifier, String statusCode) {
|
||||
this.readerIdentifier = trim(readerIdentifier);
|
||||
this.bookIdentifier = trim(bookIdentifier);
|
||||
this.statusCode = trim(statusCode);
|
||||
}
|
||||
|
||||
public String getReaderIdentifier() {
|
||||
return readerIdentifier;
|
||||
}
|
||||
|
||||
public void setReaderIdentifier(String readerIdentifier) {
|
||||
this.readerIdentifier = trim(readerIdentifier);
|
||||
}
|
||||
|
||||
public String getBookIdentifier() {
|
||||
return bookIdentifier;
|
||||
}
|
||||
|
||||
public void setBookIdentifier(String bookIdentifier) {
|
||||
this.bookIdentifier = trim(bookIdentifier);
|
||||
}
|
||||
|
||||
public String getStatusCode() {
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
public void setStatusCode(String statusCode) {
|
||||
this.statusCode = trim(statusCode);
|
||||
}
|
||||
|
||||
public boolean isOverdueOnly() {
|
||||
return OVERDUE_STATUS.equals(statusCode);
|
||||
}
|
||||
|
||||
private String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.mzh.library.entity;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public enum BorrowRecordStatus {
|
||||
ACTIVE("active", "Active"),
|
||||
RETURNED("returned", "Returned");
|
||||
|
||||
private final String code;
|
||||
private final String displayName;
|
||||
|
||||
BorrowRecordStatus(String code, String displayName) {
|
||||
this.code = code;
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public static BorrowRecordStatus fromCode(String code) {
|
||||
if (code == null || code.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("Borrow record status is required");
|
||||
}
|
||||
|
||||
String normalized = code.trim().toLowerCase(Locale.ROOT);
|
||||
for (BorrowRecordStatus status : values()) {
|
||||
if (status.code.equals(normalized)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Unsupported borrow record status: " + code);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.mzh.library.filter;
|
||||
import com.mzh.library.dao.impl.JdbcUserDao;
|
||||
import com.mzh.library.entity.AuthenticatedUser;
|
||||
import com.mzh.library.entity.Permission;
|
||||
import com.mzh.library.entity.Role;
|
||||
import com.mzh.library.service.AuthService;
|
||||
import com.mzh.library.service.impl.AuthServiceImpl;
|
||||
import com.mzh.library.util.SessionAttributes;
|
||||
@@ -25,11 +26,13 @@ public class AuthorizationFilter implements Filter {
|
||||
private static final Logger LOGGER = Logger.getLogger(AuthorizationFilter.class.getName());
|
||||
private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
|
||||
private static final List<PathRule> RULES = Arrays.asList(
|
||||
new PathRule("/borrowing", Permission.MANAGE_BORROWING),
|
||||
new PathRule("/books", Permission.MANAGE_BOOKS),
|
||||
new PathRule("/readers", Permission.MANAGE_READERS),
|
||||
new PathRule("/catalog", Permission.VIEW_CATALOG),
|
||||
new PathRule("/admin", Permission.MANAGE_USERS),
|
||||
new PathRule("/librarian", Permission.MANAGE_BORROWING),
|
||||
new PathRule("/reader/loans", Permission.BORROW_BOOKS, Role.READER),
|
||||
new PathRule("/reader", Permission.VIEW_CATALOG)
|
||||
);
|
||||
|
||||
@@ -41,38 +44,39 @@ public class AuthorizationFilter implements Filter {
|
||||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||||
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
||||
String path = relativePath(httpRequest);
|
||||
Permission requiredPermission = requiredPermission(path);
|
||||
PathRule requiredRule = requiredRule(path);
|
||||
|
||||
if (requiredPermission == null) {
|
||||
if (requiredRule == null) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
AuthenticatedUser user = currentUser(httpRequest.getSession(false));
|
||||
if (authService.hasPermission(user, requiredPermission)) {
|
||||
if (requiredRule.allows(authService, user)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
logDeniedAccess(user, requiredPermission, path);
|
||||
logDeniedAccess(user, requiredRule, path);
|
||||
httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
request.setAttribute("errorMessage", "You do not have permission to access this page.");
|
||||
request.getRequestDispatcher(UNAUTHORIZED_JSP).forward(request, response);
|
||||
}
|
||||
|
||||
private void logDeniedAccess(AuthenticatedUser user, Permission requiredPermission, String path) {
|
||||
private void logDeniedAccess(AuthenticatedUser user, PathRule requiredRule, String path) {
|
||||
String actor = user == null
|
||||
? "anonymous"
|
||||
: "userId=" + user.getId() + " role=" + user.getRole().getCode();
|
||||
LOGGER.warning("Permission denied path=" + path
|
||||
+ " requiredPermission=" + requiredPermission.getCode()
|
||||
+ " requiredPermission=" + requiredRule.permission.getCode()
|
||||
+ " requiredRole=" + requiredRule.requiredRoleCode()
|
||||
+ " actor=" + actor);
|
||||
}
|
||||
|
||||
private Permission requiredPermission(String path) {
|
||||
private PathRule requiredRule(String path) {
|
||||
for (PathRule rule : RULES) {
|
||||
if (path.equals(rule.prefix) || path.startsWith(rule.prefix + "/")) {
|
||||
return rule.permission;
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -95,10 +99,25 @@ public class AuthorizationFilter implements Filter {
|
||||
private static final class PathRule {
|
||||
private final String prefix;
|
||||
private final Permission permission;
|
||||
private final Role requiredRole;
|
||||
|
||||
private PathRule(String prefix, Permission permission) {
|
||||
this(prefix, permission, null);
|
||||
}
|
||||
|
||||
private PathRule(String prefix, Permission permission, Role requiredRole) {
|
||||
this.prefix = prefix;
|
||||
this.permission = permission;
|
||||
this.requiredRole = requiredRole;
|
||||
}
|
||||
|
||||
private boolean allows(AuthService authService, AuthenticatedUser user) {
|
||||
boolean roleAllowed = requiredRole == null || (user != null && user.getRole() == requiredRole);
|
||||
return roleAllowed && authService.hasPermission(user, permission);
|
||||
}
|
||||
|
||||
private String requiredRoleCode() {
|
||||
return requiredRole == null ? "any" : requiredRole.getCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.mzh.library.service;
|
||||
|
||||
import com.mzh.library.entity.AuthenticatedUser;
|
||||
import com.mzh.library.entity.BorrowRecord;
|
||||
import com.mzh.library.entity.BorrowRecordSearchCriteria;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface BorrowingService {
|
||||
ServiceResult<List<BorrowRecord>> searchRecords(AuthenticatedUser actor, BorrowRecordSearchCriteria criteria);
|
||||
|
||||
ServiceResult<Long> borrowBook(AuthenticatedUser actor, String readerIdentifier, String bookIdentifier);
|
||||
|
||||
ServiceResult<Void> returnBook(AuthenticatedUser actor, long recordId);
|
||||
|
||||
ServiceResult<Void> renewLoan(AuthenticatedUser actor, long recordId);
|
||||
|
||||
ServiceResult<List<BorrowRecord>> listCurrentReaderHistory(AuthenticatedUser actor);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package com.mzh.library.service.impl;
|
||||
|
||||
import com.mzh.library.dao.BorrowRecordDao;
|
||||
import com.mzh.library.entity.AuthenticatedUser;
|
||||
import com.mzh.library.entity.Book;
|
||||
import com.mzh.library.entity.BookStatus;
|
||||
import com.mzh.library.entity.BorrowRecord;
|
||||
import com.mzh.library.entity.BorrowRecordSearchCriteria;
|
||||
import com.mzh.library.entity.BorrowRecordStatus;
|
||||
import com.mzh.library.entity.Permission;
|
||||
import com.mzh.library.entity.Reader;
|
||||
import com.mzh.library.entity.ReaderStatus;
|
||||
import com.mzh.library.entity.Role;
|
||||
import com.mzh.library.exception.DaoException;
|
||||
import com.mzh.library.service.BorrowingService;
|
||||
import com.mzh.library.service.PermissionPolicy;
|
||||
import com.mzh.library.service.ServiceResult;
|
||||
import com.mzh.library.util.JdbcUtil;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class BorrowingServiceImpl implements BorrowingService {
|
||||
public interface TransactionExecutor {
|
||||
<T> T execute(JdbcUtil.TransactionCallback<T> callback);
|
||||
}
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(BorrowingServiceImpl.class.getName());
|
||||
private static final String UNAVAILABLE_MESSAGE =
|
||||
"Borrowing service is temporarily unavailable. Please try again later.";
|
||||
private static final String VALIDATION_MESSAGE = "Please correct the highlighted borrowing fields.";
|
||||
private static final String DENIED_MESSAGE = "You do not have permission to manage borrowing.";
|
||||
private static final String HISTORY_DENIED_MESSAGE = "You do not have permission to view loan history.";
|
||||
private static final int LOAN_DAYS = 14;
|
||||
private static final int MAX_RENEWALS = 1;
|
||||
|
||||
private final BorrowRecordDao borrowRecordDao;
|
||||
private final PermissionPolicy permissionPolicy;
|
||||
private final Clock clock;
|
||||
private final TransactionExecutor transactionExecutor;
|
||||
|
||||
public BorrowingServiceImpl(BorrowRecordDao borrowRecordDao) {
|
||||
this(borrowRecordDao, new PermissionPolicy(), Clock.systemDefaultZone(), new JdbcTransactionExecutor());
|
||||
}
|
||||
|
||||
public BorrowingServiceImpl(BorrowRecordDao borrowRecordDao, PermissionPolicy permissionPolicy, Clock clock,
|
||||
TransactionExecutor transactionExecutor) {
|
||||
this.borrowRecordDao = borrowRecordDao;
|
||||
this.permissionPolicy = permissionPolicy;
|
||||
this.clock = clock;
|
||||
this.transactionExecutor = transactionExecutor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<List<BorrowRecord>> searchRecords(AuthenticatedUser actor, BorrowRecordSearchCriteria criteria) {
|
||||
if (!canManageBorrowing(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
|
||||
BorrowRecordSearchCriteria normalized = criteria == null ? new BorrowRecordSearchCriteria() : criteria;
|
||||
Map<String, String> errors = validateSearch(normalized);
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure("Please correct the borrowing search filters.", errors);
|
||||
}
|
||||
|
||||
try {
|
||||
return ServiceResult.success(borrowRecordDao.search(normalized));
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to search borrow records actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Long> borrowBook(AuthenticatedUser actor, String readerIdentifier, String bookIdentifier) {
|
||||
if (!canManageBorrowing(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
|
||||
String normalizedReaderIdentifier = trim(readerIdentifier);
|
||||
String normalizedBookIdentifier = trim(bookIdentifier);
|
||||
Map<String, String> errors = validateBorrowIdentifiers(normalizedReaderIdentifier, normalizedBookIdentifier);
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
|
||||
try {
|
||||
return transactionExecutor.execute(connection -> {
|
||||
Map<String, String> transactionErrors = new LinkedHashMap<>();
|
||||
Optional<Reader> readerResult = borrowRecordDao.findReaderByIdentifierForUpdate(connection,
|
||||
normalizedReaderIdentifier);
|
||||
if (!readerResult.isPresent()) {
|
||||
transactionErrors.put("readerIdentifier", "Reader was not found.");
|
||||
}
|
||||
|
||||
Optional<Book> bookResult = borrowRecordDao.findBookByIdentifierForUpdate(connection,
|
||||
normalizedBookIdentifier);
|
||||
if (!bookResult.isPresent()) {
|
||||
transactionErrors.put("bookIdentifier", "Book was not found.");
|
||||
}
|
||||
|
||||
if (!transactionErrors.isEmpty()) {
|
||||
return ServiceResult.validationFailure(VALIDATION_MESSAGE, transactionErrors);
|
||||
}
|
||||
|
||||
Reader reader = readerResult.get();
|
||||
Book book = bookResult.get();
|
||||
validateBorrowEligibility(transactionErrors, reader, book, connection);
|
||||
if (!transactionErrors.isEmpty()) {
|
||||
return ServiceResult.validationFailure(VALIDATION_MESSAGE, transactionErrors);
|
||||
}
|
||||
|
||||
LocalDateTime borrowedAt = now();
|
||||
BorrowRecord record = new BorrowRecord();
|
||||
record.setReaderId(reader.getId());
|
||||
record.setBookId(book.getId());
|
||||
record.setBorrowedAt(borrowedAt);
|
||||
record.setDueAt(borrowedAt.plusDays(LOAN_DAYS));
|
||||
record.setRenewalCount(0);
|
||||
record.setStatus(BorrowRecordStatus.ACTIVE);
|
||||
|
||||
long id = borrowRecordDao.create(connection, record);
|
||||
if (!borrowRecordDao.decrementAvailableCopies(connection, book.getId())) {
|
||||
throw new DaoException("Book inventory was not decremented for borrow record " + id, null);
|
||||
}
|
||||
|
||||
LOGGER.info("Borrowed book recordId=" + id + " readerId=" + reader.getId()
|
||||
+ " bookId=" + book.getId() + " actorId=" + actor.getId());
|
||||
return ServiceResult.success(id, "Book borrowed.");
|
||||
});
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to borrow book actorId=" + actor.getId()
|
||||
+ " readerIdentifier=" + normalizedReaderIdentifier
|
||||
+ " bookIdentifier=" + normalizedBookIdentifier, ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Void> returnBook(AuthenticatedUser actor, long recordId) {
|
||||
if (!canManageBorrowing(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
if (recordId <= 0) {
|
||||
return ServiceResult.failure("Select a valid borrowing record.");
|
||||
}
|
||||
|
||||
try {
|
||||
return transactionExecutor.execute(connection -> {
|
||||
Optional<BorrowRecord> recordResult = borrowRecordDao.findByIdForUpdate(connection, recordId);
|
||||
if (!recordResult.isPresent()) {
|
||||
return ServiceResult.failure("Borrowing record was not found.");
|
||||
}
|
||||
|
||||
BorrowRecord record = recordResult.get();
|
||||
Map<String, String> errors = validateActiveLoan(record);
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure("Borrowing record cannot be returned.", errors);
|
||||
}
|
||||
|
||||
if (!borrowRecordDao.markReturned(connection, recordId, now())) {
|
||||
throw new DaoException("Borrow record was not marked returned: " + recordId, null);
|
||||
}
|
||||
borrowRecordDao.incrementAvailableCopies(connection, record.getBookId());
|
||||
|
||||
LOGGER.info("Returned borrow recordId=" + recordId + " actorId=" + actor.getId());
|
||||
return ServiceResult.success(null, "Book returned.");
|
||||
});
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to return borrow record id=" + recordId + " actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Void> renewLoan(AuthenticatedUser actor, long recordId) {
|
||||
if (!canManageBorrowing(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
if (recordId <= 0) {
|
||||
return ServiceResult.failure("Select a valid borrowing record.");
|
||||
}
|
||||
|
||||
try {
|
||||
return transactionExecutor.execute(connection -> {
|
||||
Optional<BorrowRecord> recordResult = borrowRecordDao.findByIdForUpdate(connection, recordId);
|
||||
if (!recordResult.isPresent()) {
|
||||
return ServiceResult.failure("Borrowing record was not found.");
|
||||
}
|
||||
|
||||
BorrowRecord record = recordResult.get();
|
||||
Map<String, String> errors = validateActiveLoan(record);
|
||||
if (record.getRenewalCount() >= MAX_RENEWALS) {
|
||||
errors.put("renewalCount", "This loan has already reached the renewal limit.");
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure("Borrowing record cannot be renewed.", errors);
|
||||
}
|
||||
|
||||
LocalDateTime currentDueAt = record.getDueAt() == null ? now() : record.getDueAt();
|
||||
LocalDateTime newDueAt = currentDueAt.plusDays(LOAN_DAYS);
|
||||
if (!borrowRecordDao.renew(connection, recordId, newDueAt)) {
|
||||
throw new DaoException("Borrow record was not renewed: " + recordId, null);
|
||||
}
|
||||
|
||||
LOGGER.info("Renewed borrow recordId=" + recordId + " actorId=" + actor.getId());
|
||||
return ServiceResult.success(null, "Loan renewed.");
|
||||
});
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to renew borrow record id=" + recordId + " actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<List<BorrowRecord>> listCurrentReaderHistory(AuthenticatedUser actor) {
|
||||
if (!canViewOwnHistory(actor)) {
|
||||
return ServiceResult.failure(HISTORY_DENIED_MESSAGE);
|
||||
}
|
||||
|
||||
try {
|
||||
Optional<Reader> readerResult = borrowRecordDao.findReaderByUserId(actor.getId());
|
||||
if (!readerResult.isPresent()) {
|
||||
return ServiceResult.success(Collections.emptyList(), "No reader profile is linked to your account.");
|
||||
}
|
||||
|
||||
return ServiceResult.success(borrowRecordDao.findByReaderId(readerResult.get().getId()));
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to load reader loan history actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
public int getMaxRenewals() {
|
||||
return MAX_RENEWALS;
|
||||
}
|
||||
|
||||
private void validateBorrowEligibility(Map<String, String> errors, Reader reader, Book book,
|
||||
java.sql.Connection connection) {
|
||||
if (reader.getStatus() != ReaderStatus.ACTIVE) {
|
||||
errors.put("readerIdentifier", "Reader must be active to borrow books.");
|
||||
}
|
||||
int activeLoans = borrowRecordDao.countActiveByReaderId(connection, reader.getId());
|
||||
if (activeLoans >= reader.getMaxBorrowCount()) {
|
||||
errors.put("readerIdentifier", "Reader has reached the active borrowing limit.");
|
||||
}
|
||||
if (book.getStatus() != BookStatus.AVAILABLE) {
|
||||
errors.put("bookIdentifier", "Book status does not allow borrowing.");
|
||||
} else if (book.getAvailableCopies() <= 0) {
|
||||
errors.put("bookIdentifier", "No available copies remain for this book.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> validateSearch(BorrowRecordSearchCriteria criteria) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
String statusCode = trim(criteria.getStatusCode());
|
||||
criteria.setReaderIdentifier(criteria.getReaderIdentifier());
|
||||
criteria.setBookIdentifier(criteria.getBookIdentifier());
|
||||
criteria.setStatusCode(statusCode);
|
||||
|
||||
if (!statusCode.isEmpty() && !BorrowRecordSearchCriteria.OVERDUE_STATUS.equals(statusCode)) {
|
||||
try {
|
||||
BorrowRecordStatus.fromCode(statusCode);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
errors.put("status", "Select a valid borrowing status.");
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private Map<String, String> validateBorrowIdentifiers(String readerIdentifier, String bookIdentifier) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
requireLength(errors, "readerIdentifier", readerIdentifier, "Reader ID", 64);
|
||||
requireLength(errors, "bookIdentifier", bookIdentifier, "Book ID", 64);
|
||||
return errors;
|
||||
}
|
||||
|
||||
private Map<String, String> validateActiveLoan(BorrowRecord record) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
if (record.getStatus() != BorrowRecordStatus.ACTIVE || record.getReturnedAt() != null) {
|
||||
errors.put("status", "Only active loans can use this action.");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private void requireLength(Map<String, String> errors, String field, String value, String label, int maxLength) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
errors.put(field, label + " is required.");
|
||||
return;
|
||||
}
|
||||
if (value.length() > maxLength) {
|
||||
errors.put(field, label + " must be " + maxLength + " characters or fewer.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canManageBorrowing(AuthenticatedUser actor) {
|
||||
return actor != null && permissionPolicy.allows(actor.getRole(), Permission.MANAGE_BORROWING);
|
||||
}
|
||||
|
||||
private boolean canViewOwnHistory(AuthenticatedUser actor) {
|
||||
return actor != null
|
||||
&& actor.getRole() == Role.READER
|
||||
&& permissionPolicy.allows(actor.getRole(), Permission.BORROW_BOOKS);
|
||||
}
|
||||
|
||||
private LocalDateTime now() {
|
||||
return LocalDateTime.now(clock);
|
||||
}
|
||||
|
||||
private String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static final class JdbcTransactionExecutor implements TransactionExecutor {
|
||||
@Override
|
||||
public <T> T execute(JdbcUtil.TransactionCallback<T> callback) {
|
||||
return JdbcUtil.executeInTransaction(callback);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DirectTransactionExecutor implements TransactionExecutor {
|
||||
@Override
|
||||
public <T> T execute(JdbcUtil.TransactionCallback<T> callback) {
|
||||
try {
|
||||
return callback.execute(null);
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to execute direct transaction", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ public final class JdbcUtil {
|
||||
private static final String CONFIG_FILE = "db.properties";
|
||||
private static final String DEFAULT_DRIVER = "com.mysql.cj.jdbc.Driver";
|
||||
|
||||
@FunctionalInterface
|
||||
public interface TransactionCallback<T> {
|
||||
T execute(Connection connection) throws SQLException;
|
||||
}
|
||||
|
||||
private JdbcUtil() {
|
||||
}
|
||||
|
||||
@@ -31,6 +36,33 @@ public final class JdbcUtil {
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> T executeInTransaction(TransactionCallback<T> callback) {
|
||||
try (Connection connection = getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
T result = callback.execute(connection);
|
||||
connection.commit();
|
||||
return result;
|
||||
} catch (SQLException ex) {
|
||||
rollbackQuietly(connection);
|
||||
throw new DaoException("Unable to complete database transaction", ex);
|
||||
} catch (RuntimeException ex) {
|
||||
rollbackQuietly(connection);
|
||||
throw ex;
|
||||
}
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to complete database transaction", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void rollbackQuietly(Connection connection) {
|
||||
try {
|
||||
connection.rollback();
|
||||
} catch (SQLException ignored) {
|
||||
// Preserve the original transaction failure for callers and logs.
|
||||
}
|
||||
}
|
||||
|
||||
private static Properties loadProperties() {
|
||||
try (InputStream inputStream = Thread.currentThread()
|
||||
.getContextClassLoader()
|
||||
|
||||
@@ -117,6 +117,31 @@ CREATE TABLE IF NOT EXISTS books (
|
||||
CHECK (status IN ('available', 'unavailable', 'archived'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS borrow_records (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
reader_id BIGINT NOT NULL,
|
||||
book_id BIGINT NOT NULL,
|
||||
borrowed_at DATETIME NOT NULL,
|
||||
due_at DATETIME NOT NULL,
|
||||
returned_at DATETIME NULL,
|
||||
renewal_count INT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_borrow_records_reader_id (reader_id),
|
||||
KEY idx_borrow_records_book_id (book_id),
|
||||
KEY idx_borrow_records_status (status),
|
||||
KEY idx_borrow_records_due_at (due_at),
|
||||
CONSTRAINT fk_borrow_records_reader
|
||||
FOREIGN KEY (reader_id) REFERENCES readers (id),
|
||||
CONSTRAINT fk_borrow_records_book
|
||||
FOREIGN KEY (book_id) REFERENCES books (id),
|
||||
CONSTRAINT chk_borrow_records_renewal_count
|
||||
CHECK (renewal_count >= 0),
|
||||
CONSTRAINT chk_borrow_records_status
|
||||
CHECK (status IN ('active', 'returned'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO roles (code, name, description) VALUES
|
||||
('administrator', 'Administrator', 'Full system administration role'),
|
||||
('librarian', 'Librarian', 'Library operation and borrowing management role'),
|
||||
@@ -146,7 +171,6 @@ INSERT IGNORE INTO role_permissions (role_code, permission_code) VALUES
|
||||
('administrator', 'view_reports'),
|
||||
('administrator', 'view_system_logs'),
|
||||
('administrator', 'view_catalog'),
|
||||
('administrator', 'borrow_books'),
|
||||
('librarian', 'manage_books'),
|
||||
('librarian', 'manage_readers'),
|
||||
('librarian', 'manage_borrowing'),
|
||||
@@ -162,17 +186,10 @@ INSERT IGNORE INTO users (username, password_hash, display_name, role_code, acti
|
||||
('librarian', 'pbkdf2_sha256$60000$bXpoLWxpYnJhcmlhbi1kZW1vLXNhbHQ=$StIdJGDRIiF4aCr+qKuwvob5sL3+6j1caF2sQNqFi78=', 'Library Staff', 'librarian', 1),
|
||||
('reader', 'pbkdf2_sha256$60000$bXpoLXJlYWRlci1kZW1vLXNhbHQ=$iaiZPGhaIQ+2R2o9UQRj6wsrmYSJ4efqS3jCzM/XU7g=', 'Demo Reader', 'reader', 1);
|
||||
|
||||
INSERT INTO readers (reader_identifier, user_id, full_name, phone, email, status, max_borrow_count) VALUES
|
||||
INSERT IGNORE INTO readers (reader_identifier, user_id, full_name, phone, email, status, max_borrow_count) VALUES
|
||||
('RD-0001', (SELECT id FROM users WHERE username = 'reader'), 'Demo Reader', '13800000000',
|
||||
'reader@example.com', 'active', 5),
|
||||
('RD-0002', NULL, 'Suspended Reader', '13900000000', 'suspended.reader@example.com', 'suspended', 3)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
user_id = VALUES(user_id),
|
||||
full_name = VALUES(full_name),
|
||||
phone = VALUES(phone),
|
||||
email = VALUES(email),
|
||||
status = VALUES(status),
|
||||
max_borrow_count = VALUES(max_borrow_count);
|
||||
('RD-0002', NULL, 'Suspended Reader', '13900000000', 'suspended.reader@example.com', 'suspended', 3);
|
||||
|
||||
INSERT INTO book_categories (name, description) VALUES
|
||||
('Computer Science', 'Programming, software engineering, and systems books'),
|
||||
@@ -182,7 +199,7 @@ INSERT INTO book_categories (name, description) VALUES
|
||||
ON DUPLICATE KEY UPDATE
|
||||
description = VALUES(description);
|
||||
|
||||
INSERT INTO books (book_identifier, title, author, category_id, total_copies, available_copies, status) VALUES
|
||||
INSERT IGNORE INTO books (book_identifier, title, author, category_id, total_copies, available_copies, status) VALUES
|
||||
('BK-0001', 'Effective Java', 'Joshua Bloch',
|
||||
(SELECT id FROM book_categories WHERE name = 'Computer Science'), 5, 4, 'available'),
|
||||
('BK-0002', 'Clean Code', 'Robert C. Martin',
|
||||
@@ -190,11 +207,4 @@ INSERT INTO books (book_identifier, title, author, category_id, total_copies, av
|
||||
('BK-0003', 'Pride and Prejudice', 'Jane Austen',
|
||||
(SELECT id FROM book_categories WHERE name = 'Literature'), 3, 3, 'available'),
|
||||
('BK-0004', 'A Brief History of Time', 'Stephen Hawking',
|
||||
(SELECT id FROM book_categories WHERE name = 'Science'), 2, 1, 'available')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title = VALUES(title),
|
||||
author = VALUES(author),
|
||||
category_id = VALUES(category_id),
|
||||
total_copies = VALUES(total_copies),
|
||||
available_copies = VALUES(available_copies),
|
||||
status = VALUES(status);
|
||||
(SELECT id FROM book_categories WHERE name = 'Science'), 2, 1, 'available');
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>New Borrow - MZH Library</title>
|
||||
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
|
||||
<main class="page-shell">
|
||||
<section class="form-panel" aria-labelledby="borrow-form-title">
|
||||
<p class="eyebrow">Borrowing Management</p>
|
||||
<h1 id="borrow-form-title">New borrow</h1>
|
||||
|
||||
<c:if test="${not empty errorMessage}">
|
||||
<div class="message message-error" role="alert">
|
||||
<c:out value="${errorMessage}" />
|
||||
</div>
|
||||
</c:if>
|
||||
|
||||
<c:set var="readerIdentifierValue" value="${formValues.readerIdentifier}" />
|
||||
<c:set var="bookIdentifierValue" value="${formValues.bookIdentifier}" />
|
||||
|
||||
<form class="borrow-form" action="${pageContext.request.contextPath}/borrowing/create" method="post" novalidate>
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label for="readerIdentifier">Reader ID</label>
|
||||
<input id="readerIdentifier" name="readerIdentifier" type="text"
|
||||
value="${fn:escapeXml(readerIdentifierValue)}" required>
|
||||
<c:if test="${not empty errors.readerIdentifier}">
|
||||
<span class="field-error"><c:out value="${errors.readerIdentifier}" /></span>
|
||||
</c:if>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="bookIdentifier">Book ID</label>
|
||||
<input id="bookIdentifier" name="bookIdentifier" type="text"
|
||||
value="${fn:escapeXml(bookIdentifierValue)}" required>
|
||||
<c:if test="${not empty errors.bookIdentifier}">
|
||||
<span class="field-error"><c:out value="${errors.bookIdentifier}" /></span>
|
||||
</c:if>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="button button-primary" type="submit">Borrow</button>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/borrowing">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,155 @@
|
||||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Borrowing Management - MZH Library</title>
|
||||
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
|
||||
<main class="page-shell">
|
||||
<section class="dashboard-hero catalog-hero" aria-labelledby="borrowing-title">
|
||||
<div>
|
||||
<p class="eyebrow">Borrowing Management</p>
|
||||
<h1 id="borrowing-title">Manage borrowing</h1>
|
||||
<p>Create borrow records, process returns, renew active loans, and review overdue items.</p>
|
||||
</div>
|
||||
<a class="button button-primary" href="${pageContext.request.contextPath}/borrowing/new">New borrow</a>
|
||||
</section>
|
||||
|
||||
<c:if test="${not empty successMessage}">
|
||||
<div class="message message-success" role="status">
|
||||
<c:out value="${successMessage}" />
|
||||
</div>
|
||||
</c:if>
|
||||
<c:if test="${not empty errorMessage}">
|
||||
<div class="message message-error" role="alert">
|
||||
<c:out value="${errorMessage}" />
|
||||
</div>
|
||||
</c:if>
|
||||
|
||||
<section class="toolbar-panel" aria-label="Borrowing search">
|
||||
<form class="search-form borrowing-search-form" action="${pageContext.request.contextPath}/borrowing" method="get">
|
||||
<div class="search-field">
|
||||
<label for="readerIdentifier">Reader ID</label>
|
||||
<input id="readerIdentifier" name="readerIdentifier" type="text"
|
||||
value="${fn:escapeXml(criteria.readerIdentifier)}">
|
||||
</div>
|
||||
|
||||
<div class="search-field">
|
||||
<label for="bookIdentifier">Book ID</label>
|
||||
<input id="bookIdentifier" name="bookIdentifier" type="text"
|
||||
value="${fn:escapeXml(criteria.bookIdentifier)}">
|
||||
</div>
|
||||
|
||||
<div class="search-field">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<option value="">All statuses</option>
|
||||
<c:forEach var="status" items="${statuses}">
|
||||
<option value="${status.code}" <c:if test="${criteria.statusCode == status.code}">selected</c:if>>
|
||||
<c:out value="${status.displayName}" />
|
||||
</option>
|
||||
</c:forEach>
|
||||
<option value="${overdueStatus}" <c:if test="${criteria.statusCode == overdueStatus}">selected</c:if>>
|
||||
Overdue
|
||||
</option>
|
||||
</select>
|
||||
<c:if test="${not empty errors.status}">
|
||||
<span class="field-error"><c:out value="${errors.status}" /></span>
|
||||
</c:if>
|
||||
</div>
|
||||
|
||||
<button class="button button-primary" type="submit">Search</button>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/borrowing">Clear</a>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="table-panel" aria-labelledby="borrowing-results-title">
|
||||
<h2 id="borrowing-results-title">Borrowing records</h2>
|
||||
<c:choose>
|
||||
<c:when test="${empty borrowRecords}">
|
||||
<p class="empty-state">No borrowing records match the current filters.</p>
|
||||
</c:when>
|
||||
<c:otherwise>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table borrowing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Reader</th>
|
||||
<th scope="col">Book</th>
|
||||
<th scope="col">Borrowed</th>
|
||||
<th scope="col">Due</th>
|
||||
<th scope="col">Returned</th>
|
||||
<th scope="col">Renewals</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<c:forEach var="record" items="${borrowRecords}">
|
||||
<tr>
|
||||
<td>
|
||||
<strong><c:out value="${record.readerIdentifier}" /></strong>
|
||||
<div class="muted-text"><c:out value="${record.readerName}" /></div>
|
||||
</td>
|
||||
<td>
|
||||
<strong><c:out value="${record.bookIdentifier}" /></strong>
|
||||
<div class="muted-text"><c:out value="${record.bookTitle}" /></div>
|
||||
</td>
|
||||
<td><c:out value="${record.borrowedAtText}" /></td>
|
||||
<td><c:out value="${record.dueAtText}" /></td>
|
||||
<td>
|
||||
<c:choose>
|
||||
<c:when test="${not empty record.returnedAtText}">
|
||||
<c:out value="${record.returnedAtText}" />
|
||||
</c:when>
|
||||
<c:otherwise>Not returned</c:otherwise>
|
||||
</c:choose>
|
||||
</td>
|
||||
<td><c:out value="${record.renewalCount}" /> / <c:out value="${maxRenewals}" /></td>
|
||||
<td>
|
||||
<span class="status-pill status-${record.displayStatusCode}">
|
||||
<c:out value="${record.displayStatusName}" />
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<c:choose>
|
||||
<c:when test="${record.status.code == 'active'}">
|
||||
<div class="table-actions">
|
||||
<form action="${pageContext.request.contextPath}/borrowing/return"
|
||||
method="post"
|
||||
onsubmit="return confirm('Return this book?');">
|
||||
<input type="hidden" name="id" value="${record.id}">
|
||||
<button class="button button-secondary" type="submit">Return</button>
|
||||
</form>
|
||||
<c:if test="${record.renewalCount < maxRenewals}">
|
||||
<form action="${pageContext.request.contextPath}/borrowing/renew"
|
||||
method="post"
|
||||
onsubmit="return confirm('Renew this loan?');">
|
||||
<input type="hidden" name="id" value="${record.id}">
|
||||
<button class="button button-secondary" type="submit">Renew</button>
|
||||
</form>
|
||||
</c:if>
|
||||
</div>
|
||||
</c:when>
|
||||
<c:otherwise>
|
||||
<span class="muted-text">Complete</span>
|
||||
</c:otherwise>
|
||||
</c:choose>
|
||||
</td>
|
||||
</tr>
|
||||
</c:forEach>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</c:otherwise>
|
||||
</c:choose>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,8 +12,12 @@
|
||||
<a href="${pageContext.request.contextPath}/librarian/home">Librarian</a>
|
||||
<a href="${pageContext.request.contextPath}/books">Books</a>
|
||||
<a href="${pageContext.request.contextPath}/readers">Readers</a>
|
||||
<a href="${pageContext.request.contextPath}/borrowing">Borrowing</a>
|
||||
</c:if>
|
||||
<a href="${pageContext.request.contextPath}/reader/home">Reader</a>
|
||||
<c:if test="${sessionScope.userRole == 'reader'}">
|
||||
<a href="${pageContext.request.contextPath}/reader/loans">My Loans</a>
|
||||
</c:if>
|
||||
<span class="user-pill">
|
||||
<c:out value="${sessionScope.authenticatedUser.displayName}" />
|
||||
</span>
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
<p>Create, update, deactivate, and review reader eligibility records.</p>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/readers">Open</a>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card">
|
||||
<h2>Borrowing Management</h2>
|
||||
<p>Create loans, process returns, renew active records, and review overdue items.</p>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/borrowing">Open</a>
|
||||
</article>
|
||||
</c:if>
|
||||
|
||||
<article class="workspace-card">
|
||||
@@ -56,9 +62,17 @@
|
||||
|
||||
<article class="workspace-card">
|
||||
<h2>Reader Center</h2>
|
||||
<p>Reader self-service entry point.</p>
|
||||
<p>Reader self-service entry point for catalog access and loan history.</p>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/reader/home">Open</a>
|
||||
</article>
|
||||
|
||||
<c:if test="${sessionScope.userRole == 'reader'}">
|
||||
<article class="workspace-card">
|
||||
<h2>My Loan History</h2>
|
||||
<p>Review your active, returned, and overdue borrowing records.</p>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/reader/loans">Open</a>
|
||||
</article>
|
||||
</c:if>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Loan History - MZH Library</title>
|
||||
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
|
||||
<main class="page-shell">
|
||||
<section class="dashboard-hero catalog-hero" aria-labelledby="loan-history-title">
|
||||
<div>
|
||||
<p class="eyebrow">Reader Center</p>
|
||||
<h1 id="loan-history-title">Loan history</h1>
|
||||
<p>Review your active, returned, and overdue borrowing records.</p>
|
||||
</div>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/catalog">Search catalog</a>
|
||||
</section>
|
||||
|
||||
<c:if test="${not empty successMessage}">
|
||||
<div class="message message-success" role="status">
|
||||
<c:out value="${successMessage}" />
|
||||
</div>
|
||||
</c:if>
|
||||
<c:if test="${not empty errorMessage}">
|
||||
<div class="message message-error" role="alert">
|
||||
<c:out value="${errorMessage}" />
|
||||
</div>
|
||||
</c:if>
|
||||
|
||||
<section class="table-panel" aria-labelledby="loan-results-title">
|
||||
<h2 id="loan-results-title">Borrowing records</h2>
|
||||
<c:choose>
|
||||
<c:when test="${empty borrowRecords}">
|
||||
<p class="empty-state">No borrowing records are available for this account.</p>
|
||||
</c:when>
|
||||
<c:otherwise>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table borrowing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Book ID</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Borrowed</th>
|
||||
<th scope="col">Due</th>
|
||||
<th scope="col">Returned</th>
|
||||
<th scope="col">Renewals</th>
|
||||
<th scope="col">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<c:forEach var="record" items="${borrowRecords}">
|
||||
<tr>
|
||||
<td><c:out value="${record.bookIdentifier}" /></td>
|
||||
<td><c:out value="${record.bookTitle}" /></td>
|
||||
<td><c:out value="${record.borrowedAtText}" /></td>
|
||||
<td><c:out value="${record.dueAtText}" /></td>
|
||||
<td>
|
||||
<c:choose>
|
||||
<c:when test="${not empty record.returnedAtText}">
|
||||
<c:out value="${record.returnedAtText}" />
|
||||
</c:when>
|
||||
<c:otherwise>Not returned</c:otherwise>
|
||||
</c:choose>
|
||||
</td>
|
||||
<td><c:out value="${record.renewalCount}" /></td>
|
||||
<td>
|
||||
<span class="status-pill status-${record.displayStatusCode}">
|
||||
<c:out value="${record.displayStatusName}" />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</c:forEach>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</c:otherwise>
|
||||
</c:choose>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -39,6 +39,20 @@
|
||||
<p>Create, update, deactivate, and review eligibility fields for reader records.</p>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/readers">Manage readers</a>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card">
|
||||
<h2>Borrowing Management</h2>
|
||||
<p>Create loans, process returns, renew records, and review overdue items.</p>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/borrowing">Manage borrowing</a>
|
||||
</article>
|
||||
</c:if>
|
||||
|
||||
<c:if test="${sessionScope.userRole == 'reader'}">
|
||||
<article class="workspace-card">
|
||||
<h2>My Loan History</h2>
|
||||
<p>Review active loans, returned records, renewal counts, and overdue status.</p>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/reader/loans">View history</a>
|
||||
</article>
|
||||
</c:if>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -110,6 +110,28 @@
|
||||
<url-pattern>/readers/delete</url-pattern>
|
||||
</servlet-mapping>
|
||||
|
||||
<servlet>
|
||||
<servlet-name>BorrowingManagementServlet</servlet-name>
|
||||
<servlet-class>com.mzh.library.controller.BorrowingManagementServlet</servlet-class>
|
||||
</servlet>
|
||||
<servlet-mapping>
|
||||
<servlet-name>BorrowingManagementServlet</servlet-name>
|
||||
<url-pattern>/borrowing</url-pattern>
|
||||
<url-pattern>/borrowing/new</url-pattern>
|
||||
<url-pattern>/borrowing/create</url-pattern>
|
||||
<url-pattern>/borrowing/return</url-pattern>
|
||||
<url-pattern>/borrowing/renew</url-pattern>
|
||||
</servlet-mapping>
|
||||
|
||||
<servlet>
|
||||
<servlet-name>ReaderLoanHistoryServlet</servlet-name>
|
||||
<servlet-class>com.mzh.library.controller.ReaderLoanHistoryServlet</servlet-class>
|
||||
</servlet>
|
||||
<servlet-mapping>
|
||||
<servlet-name>ReaderLoanHistoryServlet</servlet-name>
|
||||
<url-pattern>/reader/loans</url-pattern>
|
||||
</servlet-mapping>
|
||||
|
||||
<servlet>
|
||||
<servlet-name>UnauthorizedServlet</servlet-name>
|
||||
<servlet-class>com.mzh.library.controller.UnauthorizedServlet</servlet-class>
|
||||
|
||||
@@ -289,6 +289,10 @@ h2 {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.borrowing-search-form {
|
||||
grid-template-columns: repeat(3, minmax(120px, 1fr)) auto auto;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -305,7 +309,8 @@ h2 {
|
||||
.book-form input,
|
||||
.book-form select,
|
||||
.reader-form input,
|
||||
.reader-form select {
|
||||
.reader-form select,
|
||||
.borrow-form input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 9px 11px;
|
||||
@@ -320,7 +325,8 @@ h2 {
|
||||
.book-form input:focus,
|
||||
.book-form select:focus,
|
||||
.reader-form input:focus,
|
||||
.reader-form select:focus {
|
||||
.reader-form select:focus,
|
||||
.borrow-form input:focus {
|
||||
outline: 3px solid rgba(37, 111, 108, 0.18);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
@@ -337,6 +343,10 @@ h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.borrowing-table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 12px 10px;
|
||||
@@ -397,6 +407,21 @@ h2 {
|
||||
background: #eef1f5;
|
||||
}
|
||||
|
||||
.status-returned {
|
||||
color: var(--color-muted);
|
||||
background: #eef1f5;
|
||||
}
|
||||
|
||||
.status-overdue {
|
||||
color: #7a211a;
|
||||
background: #fff0ee;
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
color: var(--color-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -413,7 +438,8 @@ h2 {
|
||||
}
|
||||
|
||||
.book-form,
|
||||
.reader-form {
|
||||
.reader-form,
|
||||
.borrow-form {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
package com.mzh.library.service;
|
||||
|
||||
import com.mzh.library.dao.BorrowRecordDao;
|
||||
import com.mzh.library.entity.AuthenticatedUser;
|
||||
import com.mzh.library.entity.Book;
|
||||
import com.mzh.library.entity.BookStatus;
|
||||
import com.mzh.library.entity.BorrowRecord;
|
||||
import com.mzh.library.entity.BorrowRecordSearchCriteria;
|
||||
import com.mzh.library.entity.BorrowRecordStatus;
|
||||
import com.mzh.library.entity.Permission;
|
||||
import com.mzh.library.entity.Reader;
|
||||
import com.mzh.library.entity.ReaderStatus;
|
||||
import com.mzh.library.entity.Role;
|
||||
import com.mzh.library.exception.DaoException;
|
||||
import com.mzh.library.service.impl.BorrowingServiceImpl;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public final class BorrowingServiceCheck {
|
||||
private static final String UNAVAILABLE_MESSAGE =
|
||||
"Borrowing service is temporarily unavailable. Please try again later.";
|
||||
private static final Clock FIXED_CLOCK = Clock.fixed(
|
||||
Instant.parse("2026-04-27T00:00:00Z"),
|
||||
ZoneId.of("UTC")
|
||||
);
|
||||
|
||||
private BorrowingServiceCheck() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
Logger.getLogger(BorrowingServiceImpl.class.getName()).setLevel(Level.OFF);
|
||||
|
||||
InMemoryBorrowRecordDao dao = new InMemoryBorrowRecordDao();
|
||||
BorrowingService service = service(dao);
|
||||
AuthenticatedUser librarian = user(10L, Role.LIBRARIAN);
|
||||
AuthenticatedUser readerUser = user(20L, Role.READER);
|
||||
AuthenticatedUser administrator = user(30L, Role.ADMINISTRATOR);
|
||||
|
||||
Reader activeReader = reader(1L, "RD-1000", 20L, "Active Reader", ReaderStatus.ACTIVE, 1);
|
||||
Reader suspendedReader = reader(2L, "RD-1001", null, "Suspended Reader", ReaderStatus.SUSPENDED, 3);
|
||||
Reader renewingReader = reader(3L, "RD-1002", null, "Renewing Reader", ReaderStatus.ACTIVE, 2);
|
||||
dao.putReader(activeReader);
|
||||
dao.putReader(suspendedReader);
|
||||
dao.putReader(renewingReader);
|
||||
dao.putBook(book(1L, "BK-1000", "Available Book", BookStatus.AVAILABLE, 2, 1));
|
||||
dao.putBook(book(2L, "BK-1001", "Unavailable Book", BookStatus.UNAVAILABLE, 1, 1));
|
||||
dao.putBook(book(3L, "BK-1002", "No Copies", BookStatus.AVAILABLE, 1, 0));
|
||||
dao.putBook(book(4L, "BK-1003", "Renewable Book", BookStatus.AVAILABLE, 1, 1));
|
||||
dao.putBook(book(5L, "BK-1004", "Limit Book", BookStatus.AVAILABLE, 1, 1));
|
||||
|
||||
ServiceResult<Long> denied = service.borrowBook(readerUser, "RD-1000", "BK-1000");
|
||||
require(!denied.isSuccessful(), "reader should not manage borrow creation");
|
||||
require("You do not have permission to manage borrowing.".equals(denied.getMessage()),
|
||||
"reader borrow creation should return permission message");
|
||||
|
||||
ServiceResult<Long> suspended = service.borrowBook(librarian, "RD-1001", "BK-1000");
|
||||
require(!suspended.isSuccessful(), "suspended reader should not borrow");
|
||||
require(suspended.getErrors().containsKey("readerIdentifier"),
|
||||
"suspended reader failure should target readerIdentifier");
|
||||
|
||||
ServiceResult<Long> unavailable = service.borrowBook(librarian, "RD-1000", "BK-1001");
|
||||
require(!unavailable.isSuccessful(), "unavailable book should not be borrowed");
|
||||
require(unavailable.getErrors().containsKey("bookIdentifier"),
|
||||
"unavailable book failure should target bookIdentifier");
|
||||
|
||||
ServiceResult<Long> noCopies = service.borrowBook(librarian, "RD-1000", "BK-1002");
|
||||
require(!noCopies.isSuccessful(), "book with no copies should not be borrowed");
|
||||
require(noCopies.getErrors().containsKey("bookIdentifier"),
|
||||
"no-copy book failure should target bookIdentifier");
|
||||
|
||||
ServiceResult<Long> borrowed = service.borrowBook(librarian, "RD-1000", "BK-1000");
|
||||
require(borrowed.isSuccessful(), "librarian should create a valid borrow record");
|
||||
long borrowedId = borrowed.getData();
|
||||
require(dao.books.get(1L).getAvailableCopies() == 0, "borrow should decrement available copies");
|
||||
|
||||
ServiceResult<Long> overLimit = service.borrowBook(librarian, "RD-1000", "BK-1004");
|
||||
require(!overLimit.isSuccessful(), "reader at max active loans should be blocked");
|
||||
require(overLimit.getErrors().containsKey("readerIdentifier"),
|
||||
"borrow limit failure should target readerIdentifier");
|
||||
|
||||
ServiceResult<List<BorrowRecord>> history = service.listCurrentReaderHistory(readerUser);
|
||||
require(history.isSuccessful(), "linked reader should view own loan history");
|
||||
require(history.getData().size() == 1, "reader history should include own record");
|
||||
|
||||
ServiceResult<List<BorrowRecord>> staffHistory = service.listCurrentReaderHistory(administrator);
|
||||
require(!staffHistory.isSuccessful(), "staff should use management history, not reader loan history");
|
||||
require("You do not have permission to view loan history.".equals(staffHistory.getMessage()),
|
||||
"staff reader-history access should return permission message");
|
||||
|
||||
ServiceResult<Void> returned = service.returnBook(librarian, borrowedId);
|
||||
require(returned.isSuccessful(), "librarian should return an active loan");
|
||||
require(dao.records.get(borrowedId).getStatus() == BorrowRecordStatus.RETURNED,
|
||||
"return should mark record returned");
|
||||
require(dao.books.get(1L).getAvailableCopies() == 1, "return should restore available copies");
|
||||
|
||||
ServiceResult<Long> renewable = service.borrowBook(librarian, "RD-1002", "BK-1003");
|
||||
require(renewable.isSuccessful(), "renewal test borrow should succeed");
|
||||
long renewableId = renewable.getData();
|
||||
LocalDateTime firstDueAt = dao.records.get(renewableId).getDueAt();
|
||||
|
||||
ServiceResult<Void> renewed = service.renewLoan(librarian, renewableId);
|
||||
require(renewed.isSuccessful(), "first renewal should succeed");
|
||||
BorrowRecord renewedRecord = dao.records.get(renewableId);
|
||||
require(renewedRecord.getRenewalCount() == 1, "renew should increment renewal count");
|
||||
require(renewedRecord.getDueAt().equals(firstDueAt.plusDays(14)), "renew should extend due date");
|
||||
|
||||
ServiceResult<Void> secondRenewal = service.renewLoan(librarian, renewableId);
|
||||
require(!secondRenewal.isSuccessful(), "second renewal should hit the explicit renewal limit");
|
||||
require(secondRenewal.getErrors().containsKey("renewalCount"),
|
||||
"renewal limit should target renewalCount");
|
||||
|
||||
BorrowRecord overdue = record(90L, 3L, 5L, LocalDateTime.of(2000, 1, 1, 12, 0),
|
||||
LocalDateTime.of(2000, 1, 15, 12, 0), null, 0, BorrowRecordStatus.ACTIVE);
|
||||
dao.records.put(overdue.getId(), overdue);
|
||||
ServiceResult<List<BorrowRecord>> overdueSearch = service.searchRecords(librarian,
|
||||
new BorrowRecordSearchCriteria("", "", BorrowRecordSearchCriteria.OVERDUE_STATUS));
|
||||
require(overdueSearch.isSuccessful(), "overdue search should succeed");
|
||||
require(overdueSearch.getData().size() == 1, "overdue search should only return overdue active loans");
|
||||
|
||||
AuthenticatedUser unlinkedReader = user(21L, Role.READER);
|
||||
ServiceResult<List<BorrowRecord>> unlinkedHistory = service.listCurrentReaderHistory(unlinkedReader);
|
||||
require(unlinkedHistory.isSuccessful(), "unlinked reader history should return a safe empty result");
|
||||
require(unlinkedHistory.getData().isEmpty(), "unlinked reader history should be empty");
|
||||
|
||||
BorrowingService failingService = service(new FailingBorrowRecordDao());
|
||||
ServiceResult<List<BorrowRecord>> failingSearch = failingService.searchRecords(librarian,
|
||||
new BorrowRecordSearchCriteria());
|
||||
require(!failingSearch.isSuccessful(), "DAO failure should not escape search");
|
||||
require(UNAVAILABLE_MESSAGE.equals(failingSearch.getMessage()),
|
||||
"DAO failure should map to safe borrowing message");
|
||||
}
|
||||
|
||||
private static BorrowingService service(BorrowRecordDao dao) {
|
||||
return new BorrowingServiceImpl(dao, new PermissionPolicy(), FIXED_CLOCK,
|
||||
new BorrowingServiceImpl.DirectTransactionExecutor());
|
||||
}
|
||||
|
||||
private static AuthenticatedUser user(long id, Role role) {
|
||||
return new AuthenticatedUser(id, role.getCode(), role.getDisplayName(), role,
|
||||
role == Role.READER
|
||||
? EnumSet.of(Permission.VIEW_CATALOG, Permission.BORROW_BOOKS)
|
||||
: EnumSet.of(Permission.MANAGE_BORROWING, Permission.VIEW_CATALOG));
|
||||
}
|
||||
|
||||
private static Reader reader(long id, String identifier, Long userId, String fullName,
|
||||
ReaderStatus status, int maxBorrowCount) {
|
||||
Reader reader = new Reader();
|
||||
reader.setId(id);
|
||||
reader.setIdentifier(identifier);
|
||||
reader.setUserId(userId);
|
||||
reader.setFullName(fullName);
|
||||
reader.setStatus(status);
|
||||
reader.setMaxBorrowCount(maxBorrowCount);
|
||||
return reader;
|
||||
}
|
||||
|
||||
private static Book book(long id, String identifier, String title, BookStatus status,
|
||||
int totalCopies, int availableCopies) {
|
||||
Book book = new Book();
|
||||
book.setId(id);
|
||||
book.setIdentifier(identifier);
|
||||
book.setTitle(title);
|
||||
book.setAuthor("Test Author");
|
||||
book.setCategoryId(1L);
|
||||
book.setCategoryName("Test Category");
|
||||
book.setStatus(status);
|
||||
book.setTotalCopies(totalCopies);
|
||||
book.setAvailableCopies(availableCopies);
|
||||
return book;
|
||||
}
|
||||
|
||||
private static BorrowRecord record(long id, long readerId, long bookId, LocalDateTime borrowedAt,
|
||||
LocalDateTime dueAt, LocalDateTime returnedAt, int renewalCount,
|
||||
BorrowRecordStatus status) {
|
||||
BorrowRecord record = new BorrowRecord();
|
||||
record.setId(id);
|
||||
record.setReaderId(readerId);
|
||||
record.setBookId(bookId);
|
||||
record.setBorrowedAt(borrowedAt);
|
||||
record.setDueAt(dueAt);
|
||||
record.setReturnedAt(returnedAt);
|
||||
record.setRenewalCount(renewalCount);
|
||||
record.setStatus(status);
|
||||
return record;
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemoryBorrowRecordDao implements BorrowRecordDao {
|
||||
private final Map<Long, Reader> readers = new LinkedHashMap<>();
|
||||
private final Map<Long, Book> books = new LinkedHashMap<>();
|
||||
private final Map<Long, BorrowRecord> records = new LinkedHashMap<>();
|
||||
private long nextId = 1L;
|
||||
|
||||
private void putReader(Reader reader) {
|
||||
readers.put(reader.getId(), reader);
|
||||
}
|
||||
|
||||
private void putBook(Book book) {
|
||||
books.put(book.getId(), book);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BorrowRecord> search(BorrowRecordSearchCriteria criteria) {
|
||||
List<BorrowRecord> matches = new ArrayList<>();
|
||||
for (BorrowRecord record : records.values()) {
|
||||
BorrowRecord enriched = enrich(copy(record));
|
||||
if (matches(criteria.getReaderIdentifier(), enriched.getReaderIdentifier())
|
||||
&& matches(criteria.getBookIdentifier(), enriched.getBookIdentifier())
|
||||
&& statusMatches(criteria, enriched)) {
|
||||
matches.add(enriched);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BorrowRecord> findByReaderId(long readerId) {
|
||||
List<BorrowRecord> matches = new ArrayList<>();
|
||||
for (BorrowRecord record : records.values()) {
|
||||
if (record.getReaderId() == readerId) {
|
||||
matches.add(enrich(copy(record)));
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findReaderByUserId(long userId) {
|
||||
for (Reader reader : readers.values()) {
|
||||
if (reader.getUserId() != null && reader.getUserId() == userId) {
|
||||
return Optional.of(copy(reader));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findReaderByIdentifierForUpdate(Connection connection, String identifier) {
|
||||
for (Reader reader : readers.values()) {
|
||||
if (reader.getIdentifier().equals(identifier)) {
|
||||
return Optional.of(copy(reader));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Book> findBookByIdentifierForUpdate(Connection connection, String identifier) {
|
||||
for (Book book : books.values()) {
|
||||
if (book.getIdentifier().equals(identifier)) {
|
||||
return Optional.of(copy(book));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BorrowRecord> findByIdForUpdate(Connection connection, long id) {
|
||||
return Optional.ofNullable(records.get(id)).map(this::copy).map(this::enrich);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countActiveByReaderId(Connection connection, long readerId) {
|
||||
int count = 0;
|
||||
for (BorrowRecord record : records.values()) {
|
||||
if (record.getReaderId() == readerId
|
||||
&& record.getStatus() == BorrowRecordStatus.ACTIVE
|
||||
&& record.getReturnedAt() == null) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long create(Connection connection, BorrowRecord record) {
|
||||
long id = nextId++;
|
||||
BorrowRecord stored = copy(record);
|
||||
stored.setId(id);
|
||||
records.put(id, stored);
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean decrementAvailableCopies(Connection connection, long bookId) {
|
||||
Book book = books.get(bookId);
|
||||
if (book == null || book.getAvailableCopies() <= 0) {
|
||||
return false;
|
||||
}
|
||||
book.setAvailableCopies(book.getAvailableCopies() - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean incrementAvailableCopies(Connection connection, long bookId) {
|
||||
Book book = books.get(bookId);
|
||||
if (book == null) {
|
||||
return false;
|
||||
}
|
||||
book.setAvailableCopies(Math.min(book.getAvailableCopies() + 1, book.getTotalCopies()));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean markReturned(Connection connection, long id, LocalDateTime returnedAt) {
|
||||
BorrowRecord record = records.get(id);
|
||||
if (record == null || record.getStatus() != BorrowRecordStatus.ACTIVE || record.getReturnedAt() != null) {
|
||||
return false;
|
||||
}
|
||||
record.setStatus(BorrowRecordStatus.RETURNED);
|
||||
record.setReturnedAt(returnedAt);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean renew(Connection connection, long id, LocalDateTime dueAt) {
|
||||
BorrowRecord record = records.get(id);
|
||||
if (record == null || record.getStatus() != BorrowRecordStatus.ACTIVE || record.getReturnedAt() != null) {
|
||||
return false;
|
||||
}
|
||||
record.setDueAt(dueAt);
|
||||
record.setRenewalCount(record.getRenewalCount() + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean matches(String filter, String value) {
|
||||
return filter == null || filter.isEmpty() || value.contains(filter);
|
||||
}
|
||||
|
||||
private boolean statusMatches(BorrowRecordSearchCriteria criteria, BorrowRecord record) {
|
||||
String statusCode = criteria.getStatusCode();
|
||||
if (statusCode == null || statusCode.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
if (criteria.isOverdueOnly()) {
|
||||
return record.isOverdue();
|
||||
}
|
||||
return record.getStatus().getCode().equals(statusCode);
|
||||
}
|
||||
|
||||
private BorrowRecord enrich(BorrowRecord record) {
|
||||
Reader reader = readers.get(record.getReaderId());
|
||||
Book book = books.get(record.getBookId());
|
||||
if (reader != null) {
|
||||
record.setReaderIdentifier(reader.getIdentifier());
|
||||
record.setReaderName(reader.getFullName());
|
||||
}
|
||||
if (book != null) {
|
||||
record.setBookIdentifier(book.getIdentifier());
|
||||
record.setBookTitle(book.getTitle());
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private Reader copy(Reader source) {
|
||||
return reader(source.getId(), source.getIdentifier(), source.getUserId(), source.getFullName(),
|
||||
source.getStatus(), source.getMaxBorrowCount());
|
||||
}
|
||||
|
||||
private Book copy(Book source) {
|
||||
return book(source.getId(), source.getIdentifier(), source.getTitle(), source.getStatus(),
|
||||
source.getTotalCopies(), source.getAvailableCopies());
|
||||
}
|
||||
|
||||
private BorrowRecord copy(BorrowRecord source) {
|
||||
BorrowRecord copy = record(source.getId(), source.getReaderId(), source.getBookId(),
|
||||
source.getBorrowedAt(), source.getDueAt(), source.getReturnedAt(), source.getRenewalCount(),
|
||||
source.getStatus());
|
||||
copy.setReaderIdentifier(source.getReaderIdentifier());
|
||||
copy.setReaderName(source.getReaderName());
|
||||
copy.setBookIdentifier(source.getBookIdentifier());
|
||||
copy.setBookTitle(source.getBookTitle());
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FailingBorrowRecordDao implements BorrowRecordDao {
|
||||
@Override
|
||||
public List<BorrowRecord> search(BorrowRecordSearchCriteria criteria) {
|
||||
throw new DaoException("Simulated search failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BorrowRecord> findByReaderId(long readerId) {
|
||||
throw new DaoException("Simulated history failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findReaderByUserId(long userId) {
|
||||
throw new DaoException("Simulated reader failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findReaderByIdentifierForUpdate(Connection connection, String identifier) {
|
||||
throw new DaoException("Simulated reader lock failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Book> findBookByIdentifierForUpdate(Connection connection, String identifier) {
|
||||
throw new DaoException("Simulated book lock failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BorrowRecord> findByIdForUpdate(Connection connection, long id) {
|
||||
throw new DaoException("Simulated record lock failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countActiveByReaderId(Connection connection, long readerId) {
|
||||
throw new DaoException("Simulated count failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long create(Connection connection, BorrowRecord record) {
|
||||
throw new DaoException("Simulated create failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean decrementAvailableCopies(Connection connection, long bookId) {
|
||||
throw new DaoException("Simulated decrement failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean incrementAvailableCopies(Connection connection, long bookId) {
|
||||
throw new DaoException("Simulated increment failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean markReturned(Connection connection, long id, LocalDateTime returnedAt) {
|
||||
throw new DaoException("Simulated return failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean renew(Connection connection, long id, LocalDateTime dueAt) {
|
||||
throw new DaoException("Simulated renew failure", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,11 @@ public final class PermissionPolicyCheck {
|
||||
require(policy.allows(Role.ADMINISTRATOR, Permission.MANAGE_USERS), "administrator should manage users");
|
||||
require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_BORROWING), "librarian should manage borrowing");
|
||||
require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_READERS), "librarian should manage readers");
|
||||
require(!policy.allows(Role.LIBRARIAN, Permission.BORROW_BOOKS), "librarian should not borrow as a reader");
|
||||
require(!policy.allows(Role.LIBRARIAN, Permission.MANAGE_USERS), "librarian should not manage users");
|
||||
require(policy.allows(Role.READER, Permission.VIEW_CATALOG), "reader should view catalog");
|
||||
require(policy.allows(Role.READER, Permission.BORROW_BOOKS), "reader should view borrowing capabilities");
|
||||
require(!policy.allows(Role.READER, Permission.MANAGE_BORROWING), "reader should not manage borrowing");
|
||||
require(!policy.allows(Role.READER, Permission.MANAGE_BOOKS), "reader should not manage books");
|
||||
require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user