diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md index b3f3aff..ad28282 100644 --- a/.trellis/spec/backend/database-guidelines.md +++ b/.trellis/spec/backend/database-guidelines.md @@ -27,10 +27,11 @@ Implemented scaffold tables: - `book_categories`: category names and descriptions for catalog grouping. - `books`: book information, category reference, inventory counts, and catalog status. +- `readers`: reader profiles, optional login-account linkage, borrowing + eligibility, contact information, and management status. Planned module tables: -- `readers`: reader profiles, borrowing eligibility, contact information. - `borrow_records`: book-reader borrowing, return, renew, and overdue data. Record new schema changes in `src/main/resources/db/schema.sql` and update this @@ -78,6 +79,11 @@ spec with exact table names, key columns, and DAO/service contracts. - `role_permissions.permission_code` must reference `permissions.code`. - `books.status` must match `BookStatus` enum codes: `available`, `unavailable`, and `archived`. +- `readers.user_id` may reference `users.id` when a reader profile is linked to + a login account. +- `readers.status` must match `ReaderStatus` enum codes: `active`, + `suspended`, and `inactive`. +- `readers.max_borrow_count` must stay between 1 and 50. ## Scenario: Book Catalog And Management Slice @@ -187,6 +193,115 @@ books/form.jsp -> JDBC -> INSERT INTO books using request parameters books/form.jsp -> BookManagementServlet -> BookService -> BookDao -> books ``` +## Scenario: Reader Information Management Slice + +### 1. Scope / Trigger + +- Trigger: the reader profile foundation was implemented after login, + permissions, catalog search, and book management. +- Schema path: `src/main/resources/db/schema.sql`. +- JSP paths: `WEB-INF/jsp/readers/manage.jsp` and `readers/form.jsp`. + +### 2. Signatures + +- DAO signatures: `ReaderDao.search(ReaderSearchCriteria criteria)`, + `findById(long id)`, `findByIdentifier(String identifier)`, + `findByUserId(long userId)`, `create(Reader reader)`, + `update(Reader reader)`, and `deactivate(long id)`. +- Entity/search signatures: `Reader` fields are `id`, `identifier`, + nullable `userId`, nullable `username`, `fullName`, `phone`, `email`, + `status`, `maxBorrowCount`, `createdAt`, and `updatedAt`; + `ReaderSearchCriteria` fields are `identifier`, `name`, `contact`, and + `statusCode`. +- Status signature: `ReaderStatus` enum codes are `active`, `suspended`, and + `inactive`. +- Service signatures: `ReaderService.searchReaders(ReaderSearchCriteria)`, + `findReader(long id)`, `createReader(AuthenticatedUser actor, Reader reader)`, + `updateReader(AuthenticatedUser actor, Reader reader)`, and + `deactivateReader(AuthenticatedUser actor, long id)`, all returning + `ServiceResult`. +- Management routes: `GET /readers`, `GET /readers/new`, + `GET /readers/edit?id=...`, `POST /readers`, `POST /readers/update`, and + `POST /readers/delete`. +- Protected permission: `/readers*` requires `MANAGE_READERS`. +- DB signature: `readers(id, reader_identifier, user_id, full_name, phone, + email, status, max_borrow_count, created_at, updated_at)`, with unique keys + on `reader_identifier` and nullable `user_id`, indexes on name/contact/status, + a foreign key to `users(id)`, and checks for supported status values and + borrow-limit range. + +### 3. Contracts + +- `readers.reader_identifier` is the unique user-facing reader ID. +- `readers.user_id` is optional; when present, only one reader profile may link + to the same login account. +- `readers.status` stores the Java `ReaderStatus` code exactly. Soft-delete + behavior deactivates the profile by setting status to `inactive`. +- Reader searches support partial identifier, full-name, and phone/email + contact matching plus exact status filtering. +- Servlet controllers parse request fields and set JSP attributes such as + `criteria`, `readers`, `reader`, `statuses`, `formValues`, `errors`, + `errorMessage`, and `successMessage`. +- JSP pages render JavaBean properties only; they must not call DAOs or embed + SQL. +- Navigation should expose reader management to administrator/librarian users, + matching the current `MANAGE_READERS` policy. + +### 4. Validation & Error Matrix + +- Missing identifier or full name -> return to `readers/form.jsp` with field + errors. +- Missing both phone and email -> return a field error on `phone`. +- Invalid phone or email format -> return field errors on `phone` or `email`. +- Unsupported status -> return a field error on `status`. +- Max borrow count outside 1 to 50 -> return a field error on + `maxBorrowCount`. +- Non-positive linked `user_id` -> return a field error on `userId`. +- Duplicate `reader_identifier` -> return a field error on `identifier`. +- Duplicate linked `user_id` -> return a field error on `userId`. +- Reader or unauthenticated actor attempts write -> HTTP 403 through + authorization filter or service denial. +- DAO failure during list/search/write -> log server-side details and return + `Reader service is temporarily unavailable. Please try again later.` +- Successful create/update/deactivate -> redirect to `/readers` with a short + flash message. + +### 5. Good/Base/Bad Cases + +- Good: a librarian creates `RD-1003`, searches by email, edits the borrow + limit, and can later deactivate the profile without deleting future borrowing + history. +- Base: `/readers` with no filters lists reader records ordered by full name + and reader identifier. +- Bad: normal readers access `/readers`, a JSP opens JDBC, or deleting a reader + profile removes rows that future borrow records need to reference. + +### 6. Tests Required + +- Run `ReaderServiceCheck` or equivalent assertions for invalid contact, + invalid borrow limit, duplicate identifiers, duplicate linked users, reader + write denial, successful librarian CRUD-like operations, search, deactivate, + and DAO failure fallback. +- Run `PermissionPolicyCheck` to confirm librarians have `MANAGE_READERS` and + readers do not. +- Scan reader 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 +readers/form.jsp -> JDBC -> DELETE FROM readers using request parameters +``` + +#### Correct + +```text +readers/form.jsp -> ReaderManagementServlet -> ReaderService -> ReaderDao -> readers.status = inactive +``` + ## Scenario: Login And Permission Scaffold Schema ### 1. Scope / Trigger diff --git a/.trellis/tasks/04-27-continue-program/check.jsonl b/.trellis/tasks/04-27-continue-program/check.jsonl new file mode 100644 index 0000000..408e70e --- /dev/null +++ b/.trellis/tasks/04-27-continue-program/check.jsonl @@ -0,0 +1,11 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Backend checklist and architecture expectations for verifying the reader-management slice."} +{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Schema, DAO, service, permission, and test expectations to check against."} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Verify controller/service/DAO failures are handled consistently."} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Verify server-side failure logging and protected workflow logging expectations."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Backend quality gate and review checklist."} +{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend checklist for JSP/CSS verification."} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Verify reader JSP forms/tables follow local component conventions."} +{"file": ".trellis/spec/frontend/state-management.md", "reason": "Verify request/session/form state handling remains server-rendered."} +{"file": ".trellis/spec/frontend/type-safety.md", "reason": "Verify JSP/Servlet validation and display contracts."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Frontend JSP/static quality checks."} +{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Verify implemented reader management satisfies the original project requirement."} diff --git a/.trellis/tasks/04-27-continue-program/implement.jsonl b/.trellis/tasks/04-27-continue-program/implement.jsonl new file mode 100644 index 0000000..294ac77 --- /dev/null +++ b/.trellis/tasks/04-27-continue-program/implement.jsonl @@ -0,0 +1,13 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Backend pre-development checklist and layered architecture overview for JSP/Servlet/MySQL work."} +{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Backend package layout and responsibility boundaries for controller, service, DAO, entity, filter, and util classes."} +{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Schema, DAO, query, and existing book-module scenario conventions relevant to adding readers."} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Service/DAO/controller error-handling conventions for user-facing failures and logging."} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Logging boundaries for protected workflows and DAO/service failures."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Backend checks and review expectations for layered feature work."} +{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend pre-development checklist for JSP/CSS work."} +{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "JSP and static asset placement conventions for reader-management pages."} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "JSP fragment, form, table, and reusable UI conventions."} +{"file": ".trellis/spec/frontend/state-management.md", "reason": "Server-rendered request/session/form state conventions."} +{"file": ".trellis/spec/frontend/type-safety.md", "reason": "JSP/Servlet validation and JavaBean display contracts."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Frontend quality checks for JSP pages and static assets."} +{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Original project requirements listing reader information management as a core module."} diff --git a/.trellis/tasks/04-27-continue-program/prd.md b/.trellis/tasks/04-27-continue-program/prd.md new file mode 100644 index 0000000..61e38c1 --- /dev/null +++ b/.trellis/tasks/04-27-continue-program/prd.md @@ -0,0 +1,116 @@ +# brainstorm: 继续完成程序 + +## Goal + +Continue the MZH Library Management system from the current scaffold by implementing the next focused business slice after login/permission and book catalog/management: reader information management. + +## What I already know + +* User asked to "继续完成程序" without specifying the next target module. +* The project is a Java 11 Maven WAR application using JSP, Servlet, Tomcat, MySQL, and JDBC DAOs. +* Current code includes login/logout, authentication and authorization filters, role-aware dashboard/home pages, book catalog search, and administrator/librarian book management. +* The previous completed task explicitly left reader profile management, borrowing/returning/renewing/overdue handling, and full reports/statistics out of scope. +* Project requirements list these remaining core modules: + * Reader information management for profiles, borrowing eligibility, and contact information. + * Borrowing and return management for borrow, return, renew, overdue handling, and automatic collection status updates. + * Book search/statistics for borrowing rankings, inventory reports, and overdue reports. + * System maintenance/logs for key operation logs, backup support, and exception tracing. +* Existing permissions already include `manage_readers`, `manage_borrowing`, `view_reports`, `view_system_logs`, and `borrow_books`. +* Existing database schema already includes users, roles, permissions, system logs, book categories, and books. +* User selected option 1: reader information management. + +## Assumptions (temporary) + +* The next slice should stay consistent with the existing layered structure: JSP/CSS presentation -> Servlet controller -> Service -> DAO -> MySQL. +* The work should remain small enough to implement and verify in one Trellis task. +* The next module should build on the completed book and permission foundation instead of redesigning the application. + +## Open Questions + +* None currently blocking. Default scope is administrator/librarian reader profile management. + +## Requirements (evolving) + +* Preserve existing login, role permission, dashboard, catalog, and book-management behavior. +* Implement reader information management for reader profiles, contact information, borrowing eligibility, and borrowing limits. +* Add a `readers` table to `src/main/resources/db/schema.sql`, linked to reader users when applicable. +* Support listing/searching readers by reader identifier, name, phone/email, and status. +* Provide administrator/librarian reader-management actions for creating, editing, and deleting or deactivating reader profiles. +* Track eligibility/status fields needed for later borrowing work, such as active/suspended status and max borrow count. +* Link reader management from the existing dashboard or role workspace for users with `MANAGE_READERS`. +* Protect reader-management routes with `MANAGE_READERS`; normal readers must not access management screens. +* Add or update database schema, DAO, service, servlet, JSP, and checks for the selected slice. +* Keep protected workflows guarded by the existing permission model. +* Add focused checks for service rules that can run without Tomcat. + +## Acceptance Criteria (evolving) + +* [x] Administrators and librarians can reach reader management pages and create, edit, and delete or deactivate reader profiles. +* [x] Reader profiles persist and load through DAO/service layers, not directly from JSPs. +* [x] Reader search supports reader identifier, name, contact fields, and status. +* [x] Readers cannot reach reader-management write or list actions. +* [x] Invalid identifier, name, contact, status, and borrow-limit values return clear user-facing errors. +* [x] Existing completed workflows continue to compile and pass checks. +* [x] Local compile/test checks pass or blockers are documented. + +## Definition of Done (team quality bar) + +* Tests added/updated where appropriate. +* Lint / typecheck / compile checks green where available. +* Docs/notes updated if behavior changes. +* Rollout/rollback considered if risky. + +## Out of Scope (explicit) + +* Replacing JSP/Servlet with another framework. +* Production deployment automation. +* Large visual redesign unrelated to the selected workflow. +* Implementing every remaining module in one task. +* Borrowing, returning, renewing, overdue handling, and borrowing statistics. +* Full reader self-service profile editing. + +## Technical Approach + +Use the same layered pattern established by the book module: + +* `readers` schema and demo reader data in `src/main/resources/db/schema.sql`. +* Reader entity/search criteria, DAO, JDBC DAO, service interface, and service implementation. +* A `ReaderManagementServlet` mapped to `/readers`, `/readers/new`, `/readers/edit`, `/readers/update`, and `/readers/delete` or equivalent. +* JSPs under `WEB-INF/jsp/readers/` for list/search and form workflows. +* Service-level checks for validation, permission denial, duplicate identifiers, DAO failure fallback, and successful CRUD-like operations. + +## Decision (ADR-lite) + +**Context**: The project already has authentication, permissions, catalog search, and book management. Borrowing workflows need reader profile and eligibility data before they can be implemented cleanly. + +**Decision**: Implement reader information management as the next focused slice. + +**Consequences**: This adds the reader data foundation needed for future borrow/return/renew modules while keeping borrowing workflows and reports out of scope for this task. + +## Technical Notes + +* Current task directory: `.trellis/tasks/04-27-continue-program`. +* Relevant previous task: `.trellis/tasks/archive/2026-04/04-27-continue-improve-program/prd.md`. +* Project requirements source: `.trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md`. +* Spec indexes available: + * `.trellis/spec/backend/index.md` + * `.trellis/spec/frontend/index.md` +* File inventory inspected: + * `README.md` + * `pom.xml` + * `src/main/resources/db/schema.sql` + * `src/main/webapp/WEB-INF/web.xml` + * `src/main/webapp/WEB-INF/jsp/dashboard.jsp` + * `src/main/webapp/WEB-INF/jsp/role-home.jsp` + * `src/main/java/com/mzh/library/filter/AuthorizationFilter.java` + * `src/main/java/com/mzh/library/entity/Permission.java` + * Existing book/auth service and check classes under `src/main/java` and `src/test/java`. +* Reader-management implementation should reuse: + * `Permission.MANAGE_READERS` + * Existing service result and DAO exception patterns + * Existing book-module controller/JSP/check structure as the closest local pattern +* Final verification notes: + * `trellis-implement` completed the reader-management slice across schema, DAO, service, servlet, JSP, CSS, README, spec, and checks. + * `trellis-check` fixed a phone-validation gap and added service assertions for missing contact, symbol-only phone, and DAO fallback on write paths. + * Available fallback Java compile and service checks passed. + * `mvn clean package` remains blocked because Maven is not installed in this environment and local servlet/JSTL jars were not available. diff --git a/.trellis/tasks/04-27-continue-program/task.json b/.trellis/tasks/04-27-continue-program/task.json new file mode 100644 index 0000000..5f57cd8 --- /dev/null +++ b/.trellis/tasks/04-27-continue-program/task.json @@ -0,0 +1,26 @@ +{ + "id": "continue-program", + "name": "continue-program", + "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": {} +} \ No newline at end of file diff --git a/README.md b/README.md index e881398..a10d8a6 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,4 @@ mvn clean package 5. Deploy `target/library-management.war` to Tomcat. -The login flow is intentionally limited to the first scaffold slice. It supports administrator, librarian, and reader roles, stores only a safe authenticated-user snapshot in the HTTP session, and keeps authentication work in Servlet -> Service -> DAO boundaries. +The implemented scaffold slices now cover login/permission checks, catalog and book management, and reader profile management. Authentication stores only a safe authenticated-user snapshot in the HTTP session, and business workflows stay in Servlet -> Service -> DAO boundaries. diff --git a/src/main/java/com/mzh/library/controller/ReaderManagementServlet.java b/src/main/java/com/mzh/library/controller/ReaderManagementServlet.java new file mode 100644 index 0000000..aab4344 --- /dev/null +++ b/src/main/java/com/mzh/library/controller/ReaderManagementServlet.java @@ -0,0 +1,375 @@ +package com.mzh.library.controller; + +import com.mzh.library.dao.impl.JdbcReaderDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Reader; +import com.mzh.library.entity.ReaderSearchCriteria; +import com.mzh.library.entity.ReaderStatus; +import com.mzh.library.service.ReaderService; +import com.mzh.library.service.ServiceResult; +import com.mzh.library.service.impl.ReaderServiceImpl; +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 java.util.Optional; + +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 ReaderManagementServlet extends HttpServlet { + private static final String MANAGE_JSP = "/WEB-INF/jsp/readers/manage.jsp"; + private static final String FORM_JSP = "/WEB-INF/jsp/readers/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 ReaderService readerService; + + @Override + public void init() { + this.readerService = new ReaderServiceImpl(new JdbcReaderDao()); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + String path = request.getServletPath(); + if ("/readers/new".equals(path)) { + renderForm(request, response, "Create reader", "/readers", defaultReader(), Collections.emptyMap(), + Collections.emptyMap(), null); + return; + } + if ("/readers/edit".equals(path)) { + showEditForm(request, response); + return; + } + if (!"/readers".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 ("/readers".equals(path)) { + createReader(request, response); + return; + } + if ("/readers/update".equals(path)) { + updateReader(request, response); + return; + } + if ("/readers/delete".equals(path)) { + deactivateReader(request, response); + return; + } + + response.sendError(HttpServletResponse.SC_NOT_FOUND); + } + + private void showManagementList(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + ReaderSearchCriteria criteria = searchCriteria(request); + request.setAttribute("criteria", criteria); + request.setAttribute("statuses", ReaderStatus.values()); + applyFlash(request); + + ServiceResult> searchResult = readerService.searchReaders(criteria); + request.setAttribute("readers", searchResult.isSuccessful() + ? searchResult.getData() + : Collections.emptyList()); + if (!searchResult.isSuccessful()) { + request.setAttribute("errorMessage", searchResult.getMessage()); + request.setAttribute("errors", searchResult.getErrors()); + } + + request.getRequestDispatcher(MANAGE_JSP).forward(request, response); + } + + private void showEditForm(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + long id = requiredLong(request.getParameter("id"), -1L); + ServiceResult> result = readerService.findReader(id); + if (!result.isSuccessful() || !result.getData().isPresent()) { + flashError(request, result.isSuccessful() ? "Reader profile was not found." : result.getMessage()); + response.sendRedirect(request.getContextPath() + "/readers"); + return; + } + + renderForm(request, response, "Edit reader", "/readers/update", result.getData().get(), + Collections.emptyMap(), Collections.emptyMap(), null); + } + + private void createReader(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + ReaderForm form = readReaderForm(request, false); + if (!form.getErrors().isEmpty()) { + renderForm(request, response, "Create reader", "/readers", form.getReader(), form.getValues(), + form.getErrors(), "Please correct the highlighted reader fields."); + return; + } + + ServiceResult result = readerService.createReader(currentUser(request), form.getReader()); + if (!result.isSuccessful()) { + handleFormFailure(request, response, "Create reader", "/readers", form, result); + return; + } + + flashSuccess(request, result.getMessage()); + response.sendRedirect(request.getContextPath() + "/readers"); + } + + private void updateReader(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + ReaderForm form = readReaderForm(request, true); + if (!form.getErrors().isEmpty()) { + renderForm(request, response, "Edit reader", "/readers/update", form.getReader(), form.getValues(), + form.getErrors(), "Please correct the highlighted reader fields."); + return; + } + + ServiceResult result = readerService.updateReader(currentUser(request), form.getReader()); + if (!result.isSuccessful()) { + handleFormFailure(request, response, "Edit reader", "/readers/update", form, result); + return; + } + + flashSuccess(request, result.getMessage()); + response.sendRedirect(request.getContextPath() + "/readers"); + } + + private void deactivateReader(HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + long id = requiredLong(request.getParameter("id"), -1L); + ServiceResult result = readerService.deactivateReader(currentUser(request), id); + if (isPermissionDenied(result)) { + forwardDenied(request, response, result.getMessage()); + return; + } + if (result.isSuccessful()) { + flashSuccess(request, result.getMessage()); + } else { + flashError(request, result.getMessage()); + } + response.sendRedirect(request.getContextPath() + "/readers"); + } + + private void handleFormFailure(HttpServletRequest request, HttpServletResponse response, String title, + String action, ReaderForm form, ServiceResult result) + throws ServletException, IOException { + if (isPermissionDenied(result)) { + forwardDenied(request, response, result.getMessage()); + return; + } + renderForm(request, response, title, action, form.getReader(), form.getValues(), result.getErrors(), + result.getMessage()); + } + + private void renderForm(HttpServletRequest request, HttpServletResponse response, String title, String action, + Reader reader, Map formValues, Map errors, + String errorMessage) + throws ServletException, IOException { + request.setAttribute("statuses", ReaderStatus.values()); + request.setAttribute("formTitle", title); + request.setAttribute("formAction", action); + request.setAttribute("reader", reader); + 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 ReaderForm readReaderForm(HttpServletRequest request, boolean requireId) { + Map values = formValues(request); + Map errors = new LinkedHashMap<>(); + Reader reader = new Reader(); + + if (requireId) { + reader.setId(parseLong(values.get("id"), "id", "Select a valid reader.", errors)); + } + reader.setIdentifier(values.get("identifier")); + reader.setUserId(optionalPositiveLong(values.get("userId"), "userId", + "Enter a valid linked account ID.", errors)); + reader.setFullName(values.get("fullName")); + reader.setPhone(values.get("phone")); + reader.setEmail(values.get("email")); + reader.setMaxBorrowCount(parseInt(values.get("maxBorrowCount"), "maxBorrowCount", + "Enter a valid max borrow count.", errors)); + + try { + reader.setStatus(ReaderStatus.fromCode(values.get("status"))); + } catch (IllegalArgumentException ex) { + errors.put("status", "Select a status."); + } + + return new ReaderForm(reader, values, errors); + } + + private Map formValues(HttpServletRequest request) { + Map values = new LinkedHashMap<>(); + values.put("id", trim(request.getParameter("id"))); + values.put("identifier", trim(request.getParameter("identifier"))); + values.put("userId", trim(request.getParameter("userId"))); + values.put("fullName", trim(request.getParameter("fullName"))); + values.put("phone", trim(request.getParameter("phone"))); + values.put("email", trim(request.getParameter("email"))); + values.put("status", trim(request.getParameter("status"))); + values.put("maxBorrowCount", trim(request.getParameter("maxBorrowCount"))); + return values; + } + + private ReaderSearchCriteria searchCriteria(HttpServletRequest request) { + return new ReaderSearchCriteria( + request.getParameter("identifier"), + request.getParameter("name"), + request.getParameter("contact"), + request.getParameter("status") + ); + } + + private Reader defaultReader() { + Reader reader = new Reader(); + reader.setStatus(ReaderStatus.ACTIVE); + reader.setMaxBorrowCount(5); + return reader; + } + + private Long optionalPositiveLong(String value, String field, String message, Map errors) { + String trimmed = trim(value); + if (trimmed.isEmpty()) { + return null; + } + try { + long parsed = Long.parseLong(trimmed); + if (parsed <= 0) { + errors.put(field, message); + return null; + } + return parsed; + } catch (NumberFormatException ex) { + errors.put(field, message); + return null; + } + } + + private long parseLong(String value, String field, String message, Map errors) { + String trimmed = trim(value); + if (trimmed.isEmpty()) { + errors.put(field, message); + return 0L; + } + try { + long parsed = Long.parseLong(trimmed); + if (parsed <= 0) { + errors.put(field, message); + } + return parsed; + } catch (NumberFormatException ex) { + errors.put(field, message); + return 0L; + } + } + + private int parseInt(String value, String field, String message, Map errors) { + String trimmed = trim(value); + if (trimmed.isEmpty()) { + errors.put(field, message); + return -1; + } + try { + return Integer.parseInt(trimmed); + } catch (NumberFormatException ex) { + errors.put(field, message); + return -1; + } + } + + 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 boolean isPermissionDenied(ServiceResult result) { + return !result.isSuccessful() && "You do not have permission to manage readers.".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(); + } + + private static final class ReaderForm { + private final Reader reader; + private final Map values; + private final Map errors; + + private ReaderForm(Reader reader, Map values, Map errors) { + this.reader = reader; + this.values = values; + this.errors = errors; + } + + private Reader getReader() { + return reader; + } + + private Map getValues() { + return values; + } + + private Map getErrors() { + return errors; + } + } +} diff --git a/src/main/java/com/mzh/library/dao/ReaderDao.java b/src/main/java/com/mzh/library/dao/ReaderDao.java new file mode 100644 index 0000000..21613f8 --- /dev/null +++ b/src/main/java/com/mzh/library/dao/ReaderDao.java @@ -0,0 +1,23 @@ +package com.mzh.library.dao; + +import com.mzh.library.entity.Reader; +import com.mzh.library.entity.ReaderSearchCriteria; + +import java.util.List; +import java.util.Optional; + +public interface ReaderDao { + List search(ReaderSearchCriteria criteria); + + Optional findById(long id); + + Optional findByIdentifier(String identifier); + + Optional findByUserId(long userId); + + long create(Reader reader); + + boolean update(Reader reader); + + boolean deactivate(long id); +} diff --git a/src/main/java/com/mzh/library/dao/impl/JdbcReaderDao.java b/src/main/java/com/mzh/library/dao/impl/JdbcReaderDao.java new file mode 100644 index 0000000..6720955 --- /dev/null +++ b/src/main/java/com/mzh/library/dao/impl/JdbcReaderDao.java @@ -0,0 +1,224 @@ +package com.mzh.library.dao.impl; + +import com.mzh.library.dao.ReaderDao; +import com.mzh.library.entity.Reader; +import com.mzh.library.entity.ReaderSearchCriteria; +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.sql.Types; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class JdbcReaderDao implements ReaderDao { + 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 FIND_BY_ID = "SELECT " + READER_COLUMNS + READER_FROM + "WHERE r.id = ?"; + + private static final String FIND_BY_IDENTIFIER = "SELECT " + READER_COLUMNS + READER_FROM + + "WHERE r.reader_identifier = ?"; + + private static final String FIND_BY_USER_ID = "SELECT " + READER_COLUMNS + READER_FROM + + "WHERE r.user_id = ?"; + + private static final String CREATE = "" + + "INSERT INTO readers " + + "(reader_identifier, user_id, full_name, phone, email, status, max_borrow_count) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)"; + + private static final String UPDATE = "" + + "UPDATE readers " + + "SET reader_identifier = ?, user_id = ?, full_name = ?, phone = ?, email = ?, " + + "status = ?, max_borrow_count = ? " + + "WHERE id = ?"; + + private static final String DEACTIVATE = "UPDATE readers SET status = ? WHERE id = ?"; + + @Override + public List search(ReaderSearchCriteria criteria) { + List parameters = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT ") + .append(READER_COLUMNS) + .append(READER_FROM) + .append("WHERE 1 = 1 "); + + appendLike(sql, parameters, "r.reader_identifier", criteria.getIdentifier()); + appendLike(sql, parameters, "r.full_name", criteria.getName()); + appendContact(sql, parameters, criteria.getContact()); + if (criteria.getStatusCode() != null && !criteria.getStatusCode().isEmpty()) { + sql.append("AND r.status = ? "); + parameters.add(criteria.getStatusCode()); + } + sql.append("ORDER BY r.full_name, r.reader_identifier"); + + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql.toString())) { + bind(statement, parameters); + try (ResultSet resultSet = statement.executeQuery()) { + List readers = new ArrayList<>(); + while (resultSet.next()) { + readers.add(mapReader(resultSet)); + } + return readers; + } + } catch (SQLException | IllegalArgumentException ex) { + throw new DaoException("Unable to search readers", ex); + } + } + + @Override + public Optional findById(long id) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(FIND_BY_ID)) { + statement.setLong(1, id); + 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 id", ex); + } + } + + @Override + public Optional findByIdentifier(String identifier) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(FIND_BY_IDENTIFIER)) { + 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 load reader by identifier", ex); + } + } + + @Override + public Optional findByUserId(long userId) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(FIND_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", ex); + } + } + + @Override + public long create(Reader reader) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(CREATE, Statement.RETURN_GENERATED_KEYS)) { + bindReader(statement, reader); + statement.executeUpdate(); + + try (ResultSet generatedKeys = statement.getGeneratedKeys()) { + if (generatedKeys.next()) { + return generatedKeys.getLong(1); + } + } + throw new DaoException("Unable to read generated reader id", null); + } catch (SQLException ex) { + throw new DaoException("Unable to create reader", ex); + } + } + + @Override + public boolean update(Reader reader) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(UPDATE)) { + bindReader(statement, reader); + statement.setLong(8, reader.getId()); + return statement.executeUpdate() == 1; + } catch (SQLException ex) { + throw new DaoException("Unable to update reader", ex); + } + } + + @Override + public boolean deactivate(long id) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(DEACTIVATE)) { + statement.setString(1, ReaderStatus.INACTIVE.getCode()); + statement.setLong(2, id); + return statement.executeUpdate() == 1; + } catch (SQLException ex) { + throw new DaoException("Unable to deactivate reader", ex); + } + } + + private void appendLike(StringBuilder sql, List 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 appendContact(StringBuilder sql, List parameters, String value) { + if (value == null || value.trim().isEmpty()) { + return; + } + sql.append("AND (r.phone LIKE ? OR r.email LIKE ?) "); + String filter = "%" + value.trim() + "%"; + parameters.add(filter); + parameters.add(filter); + } + + private void bind(PreparedStatement statement, List parameters) throws SQLException { + for (int i = 0; i < parameters.size(); i++) { + Object value = parameters.get(i); + statement.setString(i + 1, value.toString()); + } + } + + private void bindReader(PreparedStatement statement, Reader reader) throws SQLException { + statement.setString(1, reader.getIdentifier()); + if (reader.getUserId() == null) { + statement.setNull(2, Types.BIGINT); + } else { + statement.setLong(2, reader.getUserId()); + } + statement.setString(3, reader.getFullName()); + statement.setString(4, reader.getPhone()); + statement.setString(5, reader.getEmail()); + statement.setString(6, reader.getStatus().getCode()); + statement.setInt(7, reader.getMaxBorrowCount()); + } + + 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 LocalDateTime toLocalDateTime(Timestamp timestamp) { + return timestamp == null ? null : timestamp.toLocalDateTime(); + } +} diff --git a/src/main/java/com/mzh/library/entity/Reader.java b/src/main/java/com/mzh/library/entity/Reader.java new file mode 100644 index 0000000..8b0da49 --- /dev/null +++ b/src/main/java/com/mzh/library/entity/Reader.java @@ -0,0 +1,105 @@ +package com.mzh.library.entity; + +import java.time.LocalDateTime; + +public class Reader { + private long id; + private String identifier; + private Long userId; + private String username; + private String fullName; + private String phone; + private String email; + private ReaderStatus status; + private int maxBorrowCount; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getIdentifier() { + return identifier; + } + + public void setIdentifier(String identifier) { + this.identifier = identifier; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public ReaderStatus getStatus() { + return status; + } + + public void setStatus(ReaderStatus status) { + this.status = status; + } + + public int getMaxBorrowCount() { + return maxBorrowCount; + } + + public void setMaxBorrowCount(int maxBorrowCount) { + this.maxBorrowCount = maxBorrowCount; + } + + 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; + } +} diff --git a/src/main/java/com/mzh/library/entity/ReaderSearchCriteria.java b/src/main/java/com/mzh/library/entity/ReaderSearchCriteria.java new file mode 100644 index 0000000..46010f0 --- /dev/null +++ b/src/main/java/com/mzh/library/entity/ReaderSearchCriteria.java @@ -0,0 +1,54 @@ +package com.mzh.library.entity; + +public class ReaderSearchCriteria { + private String identifier; + private String name; + private String contact; + private String statusCode; + + public ReaderSearchCriteria() { + } + + public ReaderSearchCriteria(String identifier, String name, String contact, String statusCode) { + this.identifier = trim(identifier); + this.name = trim(name); + this.contact = trim(contact); + this.statusCode = trim(statusCode); + } + + public String getIdentifier() { + return identifier; + } + + public void setIdentifier(String identifier) { + this.identifier = trim(identifier); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = trim(name); + } + + public String getContact() { + return contact; + } + + public void setContact(String contact) { + this.contact = trim(contact); + } + + public String getStatusCode() { + return statusCode; + } + + public void setStatusCode(String statusCode) { + this.statusCode = trim(statusCode); + } + + private String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/src/main/java/com/mzh/library/entity/ReaderStatus.java b/src/main/java/com/mzh/library/entity/ReaderStatus.java new file mode 100644 index 0000000..bc59248 --- /dev/null +++ b/src/main/java/com/mzh/library/entity/ReaderStatus.java @@ -0,0 +1,40 @@ +package com.mzh.library.entity; + +import java.util.Locale; + +public enum ReaderStatus { + ACTIVE("active", "Active"), + SUSPENDED("suspended", "Suspended"), + INACTIVE("inactive", "Inactive"); + + private final String code; + private final String displayName; + + ReaderStatus(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public static ReaderStatus fromCode(String code) { + if (code == null || code.trim().isEmpty()) { + throw new IllegalArgumentException("Reader status is required"); + } + + String normalized = code.trim().toLowerCase(Locale.ROOT); + for (ReaderStatus status : values()) { + if (status.code.equals(normalized)) { + return status; + } + } + + throw new IllegalArgumentException("Unsupported reader status: " + code); + } +} diff --git a/src/main/java/com/mzh/library/filter/AuthorizationFilter.java b/src/main/java/com/mzh/library/filter/AuthorizationFilter.java index 61bd939..db8cc44 100644 --- a/src/main/java/com/mzh/library/filter/AuthorizationFilter.java +++ b/src/main/java/com/mzh/library/filter/AuthorizationFilter.java @@ -26,6 +26,7 @@ public class AuthorizationFilter implements Filter { private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp"; private static final List RULES = Arrays.asList( 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), diff --git a/src/main/java/com/mzh/library/service/ReaderService.java b/src/main/java/com/mzh/library/service/ReaderService.java new file mode 100644 index 0000000..06403d1 --- /dev/null +++ b/src/main/java/com/mzh/library/service/ReaderService.java @@ -0,0 +1,20 @@ +package com.mzh.library.service; + +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Reader; +import com.mzh.library.entity.ReaderSearchCriteria; + +import java.util.List; +import java.util.Optional; + +public interface ReaderService { + ServiceResult> searchReaders(ReaderSearchCriteria criteria); + + ServiceResult> findReader(long id); + + ServiceResult createReader(AuthenticatedUser actor, Reader reader); + + ServiceResult updateReader(AuthenticatedUser actor, Reader reader); + + ServiceResult deactivateReader(AuthenticatedUser actor, long id); +} diff --git a/src/main/java/com/mzh/library/service/impl/ReaderServiceImpl.java b/src/main/java/com/mzh/library/service/impl/ReaderServiceImpl.java new file mode 100644 index 0000000..48e8e31 --- /dev/null +++ b/src/main/java/com/mzh/library/service/impl/ReaderServiceImpl.java @@ -0,0 +1,245 @@ +package com.mzh.library.service.impl; + +import com.mzh.library.dao.ReaderDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Permission; +import com.mzh.library.entity.Reader; +import com.mzh.library.entity.ReaderSearchCriteria; +import com.mzh.library.entity.ReaderStatus; +import com.mzh.library.exception.DaoException; +import com.mzh.library.service.PermissionPolicy; +import com.mzh.library.service.ReaderService; +import com.mzh.library.service.ServiceResult; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class ReaderServiceImpl implements ReaderService { + private static final Logger LOGGER = Logger.getLogger(ReaderServiceImpl.class.getName()); + private static final String UNAVAILABLE_MESSAGE = + "Reader service is temporarily unavailable. Please try again later."; + private static final String VALIDATION_MESSAGE = "Please correct the highlighted reader fields."; + private static final String SEARCH_VALIDATION_MESSAGE = "Please correct the reader search filters."; + private static final String DENIED_MESSAGE = "You do not have permission to manage readers."; + private static final int MAX_BORROW_LIMIT = 50; + private static final Pattern PHONE_PATTERN = Pattern.compile("(?=.*\\d)[0-9+()\\-\\s]{6,32}"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"); + + private final ReaderDao readerDao; + private final PermissionPolicy permissionPolicy; + + public ReaderServiceImpl(ReaderDao readerDao) { + this(readerDao, new PermissionPolicy()); + } + + public ReaderServiceImpl(ReaderDao readerDao, PermissionPolicy permissionPolicy) { + this.readerDao = readerDao; + this.permissionPolicy = permissionPolicy; + } + + @Override + public ServiceResult> searchReaders(ReaderSearchCriteria criteria) { + ReaderSearchCriteria normalized = criteria == null ? new ReaderSearchCriteria() : criteria; + Map errors = validateSearch(normalized); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(SEARCH_VALIDATION_MESSAGE, errors); + } + + try { + return ServiceResult.success(readerDao.search(normalized)); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to search readers", ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult> findReader(long id) { + if (id <= 0) { + return ServiceResult.failure("Select a valid reader."); + } + + try { + return ServiceResult.success(readerDao.findById(id)); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to load reader id=" + id, ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult createReader(AuthenticatedUser actor, Reader reader) { + if (!canManageReaders(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + normalize(reader); + Map errors = validate(reader, false); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + try { + if (readerDao.findByIdentifier(reader.getIdentifier()).isPresent()) { + errors.put("identifier", "Reader identifier is already in use."); + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + if (reader.getUserId() != null && readerDao.findByUserId(reader.getUserId()).isPresent()) { + errors.put("userId", "Linked account is already assigned to a reader profile."); + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + long id = readerDao.create(reader); + LOGGER.info("Created reader id=" + id + " actorId=" + actor.getId()); + return ServiceResult.success(id, "Reader profile created."); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to create reader actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult updateReader(AuthenticatedUser actor, Reader reader) { + if (!canManageReaders(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + normalize(reader); + Map errors = validate(reader, true); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + try { + Optional existingWithIdentifier = readerDao.findByIdentifier(reader.getIdentifier()); + if (existingWithIdentifier.isPresent() && existingWithIdentifier.get().getId() != reader.getId()) { + errors.put("identifier", "Reader identifier is already in use."); + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + if (reader.getUserId() != null) { + Optional existingWithUser = readerDao.findByUserId(reader.getUserId()); + if (existingWithUser.isPresent() && existingWithUser.get().getId() != reader.getId()) { + errors.put("userId", "Linked account is already assigned to a reader profile."); + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + } + + if (!readerDao.update(reader)) { + return ServiceResult.failure("Reader profile was not found."); + } + + LOGGER.info("Updated reader id=" + reader.getId() + " actorId=" + actor.getId()); + return ServiceResult.success(null, "Reader profile updated."); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to update reader id=" + reader.getId() + " actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult deactivateReader(AuthenticatedUser actor, long id) { + if (!canManageReaders(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + if (id <= 0) { + return ServiceResult.failure("Select a valid reader."); + } + + try { + if (!readerDao.deactivate(id)) { + return ServiceResult.failure("Reader profile was not found."); + } + + LOGGER.info("Deactivated reader id=" + id + " actorId=" + actor.getId()); + return ServiceResult.success(null, "Reader profile deactivated."); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to deactivate reader id=" + id + " actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + private boolean canManageReaders(AuthenticatedUser actor) { + return actor != null && permissionPolicy.allows(actor.getRole(), Permission.MANAGE_READERS); + } + + private void normalize(Reader reader) { + if (reader == null) { + return; + } + reader.setIdentifier(trim(reader.getIdentifier())); + reader.setFullName(trim(reader.getFullName())); + reader.setPhone(trim(reader.getPhone())); + reader.setEmail(trim(reader.getEmail())); + } + + private Map validateSearch(ReaderSearchCriteria criteria) { + Map errors = new LinkedHashMap<>(); + if (criteria.getStatusCode() != null && !criteria.getStatusCode().isEmpty()) { + try { + criteria.setStatusCode(ReaderStatus.fromCode(criteria.getStatusCode()).getCode()); + } catch (IllegalArgumentException ex) { + errors.put("status", "Select a valid status."); + } + } + return errors; + } + + private Map validate(Reader reader, boolean requireId) { + Map errors = new LinkedHashMap<>(); + if (reader == null) { + errors.put("reader", "Reader details are required."); + return errors; + } + + if (requireId && reader.getId() <= 0) { + errors.put("id", "Select a valid reader."); + } + requireLength(errors, "identifier", reader.getIdentifier(), "Reader identifier", 64); + requireLength(errors, "fullName", reader.getFullName(), "Full name", 100); + if (reader.getUserId() != null && reader.getUserId() <= 0) { + errors.put("userId", "Linked account ID must be positive."); + } + validateContact(errors, reader); + if (reader.getStatus() == null) { + errors.put("status", "Select a status."); + } + if (reader.getMaxBorrowCount() < 1 || reader.getMaxBorrowCount() > MAX_BORROW_LIMIT) { + errors.put("maxBorrowCount", "Max borrow count must be between 1 and " + MAX_BORROW_LIMIT + "."); + } + return errors; + } + + private void validateContact(Map errors, Reader reader) { + String phone = reader.getPhone(); + String email = reader.getEmail(); + if ((phone == null || phone.isEmpty()) && (email == null || email.isEmpty())) { + errors.put("phone", "Phone or email is required."); + return; + } + if (phone != null && !phone.isEmpty() && !PHONE_PATTERN.matcher(phone).matches()) { + errors.put("phone", "Phone must include a digit and use 6 to 32 digits or common phone symbols."); + } + if (email != null && !email.isEmpty() && !EMAIL_PATTERN.matcher(email).matches()) { + errors.put("email", "Email must be a valid address."); + } + } + + private void requireLength(Map 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 String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 82a3671..725908c 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -57,6 +57,31 @@ CREATE TABLE IF NOT EXISTS system_logs ( KEY idx_system_logs_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS readers ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + reader_identifier VARCHAR(64) NOT NULL, + user_id BIGINT NULL, + full_name VARCHAR(100) NOT NULL, + phone VARCHAR(32) NULL, + email VARCHAR(120) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + max_borrow_count INT NOT NULL DEFAULT 5, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_readers_identifier (reader_identifier), + UNIQUE KEY uk_readers_user_id (user_id), + KEY idx_readers_full_name (full_name), + KEY idx_readers_phone (phone), + KEY idx_readers_email (email), + KEY idx_readers_status (status), + CONSTRAINT fk_readers_user + FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT chk_readers_status + CHECK (status IN ('active', 'suspended', 'inactive')), + CONSTRAINT chk_readers_max_borrow_count + CHECK (max_borrow_count BETWEEN 1 AND 50) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS book_categories ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(96) NOT NULL, @@ -137,6 +162,18 @@ 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 + ('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); + INSERT INTO book_categories (name, description) VALUES ('Computer Science', 'Programming, software engineering, and systems books'), ('Literature', 'Classic and modern literature'), diff --git a/src/main/webapp/WEB-INF/jsp/common/header.jspf b/src/main/webapp/WEB-INF/jsp/common/header.jspf index 5b88a5e..9d549d4 100644 --- a/src/main/webapp/WEB-INF/jsp/common/header.jspf +++ b/src/main/webapp/WEB-INF/jsp/common/header.jspf @@ -11,6 +11,7 @@ Librarian Books + Readers Reader diff --git a/src/main/webapp/WEB-INF/jsp/dashboard.jsp b/src/main/webapp/WEB-INF/jsp/dashboard.jsp index f52dc5c..a80ba42 100644 --- a/src/main/webapp/WEB-INF/jsp/dashboard.jsp +++ b/src/main/webapp/WEB-INF/jsp/dashboard.jsp @@ -40,6 +40,12 @@

Create, update, delete, and review book inventory records.

Open + +
+

Reader Management

+

Create, update, deactivate, and review reader eligibility records.

+ Open +
diff --git a/src/main/webapp/WEB-INF/jsp/readers/form.jsp b/src/main/webapp/WEB-INF/jsp/readers/form.jsp new file mode 100644 index 0000000..8030ef4 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/readers/form.jsp @@ -0,0 +1,113 @@ +<%@ 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" %> + + + + + + <c:out value="${formTitle}" /> - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+

Reader Management

+

+ + + + + + + + + + + + + + +
+ + + + +
+
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+
+ +
+ + Cancel +
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/readers/manage.jsp b/src/main/webapp/WEB-INF/jsp/readers/manage.jsp new file mode 100644 index 0000000..f4caa30 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/readers/manage.jsp @@ -0,0 +1,139 @@ +<%@ 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" %> + + + + + + Manage Readers - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+

Reader Management

+

Manage readers

+

Create, update, and review reader eligibility and contact records.

+ New reader +
+ + +
+ +
+
+ + + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + + +
+ + + Clear +
+
+ +
+

Reader records

+ + +

No reader records match the current filters.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Reader IDNameContactAccountBorrow limitStatusActions
+ +
+
+ +
+
+
+ + + + + Unlinked + + + + + + +
+ Edit +
+ + +
+
+
+
+
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/role-home.jsp b/src/main/webapp/WEB-INF/jsp/role-home.jsp index 7d4504d..6ec1291 100644 --- a/src/main/webapp/WEB-INF/jsp/role-home.jsp +++ b/src/main/webapp/WEB-INF/jsp/role-home.jsp @@ -33,6 +33,12 @@

Create, update, delete, and review inventory fields for book records.

Manage books
+ +
+

Reader Management

+

Create, update, deactivate, and review eligibility fields for reader records.

+ Manage readers +
diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index e1ace51..8202aa3 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -97,6 +97,19 @@ /books/delete + + ReaderManagementServlet + com.mzh.library.controller.ReaderManagementServlet + + + ReaderManagementServlet + /readers + /readers/new + /readers/edit + /readers/update + /readers/delete + + UnauthorizedServlet com.mzh.library.controller.UnauthorizedServlet diff --git a/src/main/webapp/static/css/app.css b/src/main/webapp/static/css/app.css index ed9b488..5b62a3e 100644 --- a/src/main/webapp/static/css/app.css +++ b/src/main/webapp/static/css/app.css @@ -303,7 +303,9 @@ h2 { .search-form input, .search-form select, .book-form input, -.book-form select { +.book-form select, +.reader-form input, +.reader-form select { width: 100%; min-height: 42px; padding: 9px 11px; @@ -316,7 +318,9 @@ h2 { .search-form input:focus, .search-form select:focus, .book-form input:focus, -.book-form select:focus { +.book-form select:focus, +.reader-form input:focus, +.reader-form select:focus { outline: 3px solid rgba(37, 111, 108, 0.18); border-color: var(--color-primary); } @@ -378,6 +382,21 @@ h2 { background: #eef1f5; } +.status-active { + color: var(--color-success); + background: #edf8ef; +} + +.status-suspended { + color: var(--color-warning); + background: #fff7e5; +} + +.status-inactive { + color: var(--color-muted); + background: #eef1f5; +} + .table-actions { display: flex; gap: 8px; @@ -393,7 +412,8 @@ h2 { max-width: 860px; } -.book-form { +.book-form, +.reader-form { display: grid; gap: 20px; } diff --git a/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java b/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java index 76e6c16..25ba4ba 100644 --- a/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java +++ b/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java @@ -12,9 +12,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.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.MANAGE_BOOKS), "reader should not manage books"); + require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers"); } private static void require(boolean condition, String message) { diff --git a/src/test/java/com/mzh/library/service/ReaderServiceCheck.java b/src/test/java/com/mzh/library/service/ReaderServiceCheck.java new file mode 100644 index 0000000..2af82b8 --- /dev/null +++ b/src/test/java/com/mzh/library/service/ReaderServiceCheck.java @@ -0,0 +1,287 @@ +package com.mzh.library.service; + +import com.mzh.library.dao.ReaderDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Permission; +import com.mzh.library.entity.Reader; +import com.mzh.library.entity.ReaderSearchCriteria; +import com.mzh.library.entity.ReaderStatus; +import com.mzh.library.entity.Role; +import com.mzh.library.exception.DaoException; +import com.mzh.library.service.impl.ReaderServiceImpl; + +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 ReaderServiceCheck { + private static final String UNAVAILABLE_MESSAGE = + "Reader service is temporarily unavailable. Please try again later."; + + private ReaderServiceCheck() { + } + + public static void main(String[] args) { + Logger.getLogger(ReaderServiceImpl.class.getName()).setLevel(Level.OFF); + + InMemoryReaderDao dao = new InMemoryReaderDao(); + ReaderService service = new ReaderServiceImpl(dao); + AuthenticatedUser librarian = user(10L, Role.LIBRARIAN); + AuthenticatedUser readerUser = user(20L, Role.READER); + + ServiceResult invalidContact = service.createReader(librarian, + reader(0L, "RD-1000", null, "Invalid Contact", "bad", "", ReaderStatus.ACTIVE, 5)); + require(!invalidContact.isSuccessful(), "invalid contact should fail"); + require(invalidContact.getErrors().containsKey("phone"), "invalid phone should target phone field"); + + ServiceResult symbolsOnlyPhone = service.createReader(librarian, + reader(0L, "RD-1009", null, "Symbols Only Phone", "------", "", ReaderStatus.ACTIVE, 5)); + require(!symbolsOnlyPhone.isSuccessful(), "phone without digits should fail"); + require(symbolsOnlyPhone.getErrors().containsKey("phone"), "phone without digits should target phone field"); + + ServiceResult missingContact = service.createReader(librarian, + reader(0L, "RD-1008", null, "Missing Contact", "", "", ReaderStatus.ACTIVE, 5)); + require(!missingContact.isSuccessful(), "missing contact should fail"); + require(missingContact.getErrors().containsKey("phone"), "missing contact should target phone field"); + + ServiceResult invalidBorrowLimit = service.createReader(librarian, + reader(0L, "RD-1001", null, "Invalid Limit", "13800000001", "", ReaderStatus.ACTIVE, 0)); + require(!invalidBorrowLimit.isSuccessful(), "invalid borrow limit should fail"); + require(invalidBorrowLimit.getErrors().containsKey("maxBorrowCount"), + "invalid borrow limit should target maxBorrowCount field"); + + ServiceResult invalidUserId = service.createReader(librarian, + reader(0L, "RD-1002", -1L, "Invalid User", "13800000002", "", ReaderStatus.ACTIVE, 5)); + require(!invalidUserId.isSuccessful(), "invalid linked account should fail"); + require(invalidUserId.getErrors().containsKey("userId"), + "invalid linked account should target userId field"); + + ServiceResult denied = service.createReader(readerUser, + reader(0L, "RD-1006", null, "Reader Write", "13800000007", "", ReaderStatus.ACTIVE, 5)); + require(!denied.isSuccessful(), "reader write should fail"); + require("You do not have permission to manage readers.".equals(denied.getMessage()), + "reader write should return permission message"); + + ServiceResult created = service.createReader(librarian, + reader(0L, "RD-1003", 30L, "Service Reader", "13800000003", + "service.reader@example.com", ReaderStatus.ACTIVE, 5)); + require(created.isSuccessful(), "librarian should create a valid reader"); + long createdId = created.getData(); + + ServiceResult duplicateIdentifier = service.createReader(librarian, + reader(0L, "RD-1003", null, "Duplicate Identifier", "13800000004", "", ReaderStatus.ACTIVE, 5)); + require(!duplicateIdentifier.isSuccessful(), "duplicate reader identifier should fail"); + require(duplicateIdentifier.getErrors().containsKey("identifier"), + "duplicate identifier should target identifier field"); + + ServiceResult duplicateUser = service.createReader(librarian, + reader(0L, "RD-1004", 30L, "Duplicate User", "13800000005", "", ReaderStatus.ACTIVE, 5)); + require(!duplicateUser.isSuccessful(), "duplicate linked account should fail"); + require(duplicateUser.getErrors().containsKey("userId"), "duplicate linked account should target userId field"); + + ServiceResult updated = service.updateReader(librarian, + reader(createdId, "RD-1005", 30L, "Updated Reader", "13800000006", + "updated.reader@example.com", ReaderStatus.SUSPENDED, 3)); + require(updated.isSuccessful(), "librarian should update a valid reader"); + require(dao.findById(createdId).get().getStatus() == ReaderStatus.SUSPENDED, + "update should persist reader status"); + + ServiceResult> search = service.searchReaders( + new ReaderSearchCriteria("RD-1005", "Updated", "updated.reader", "suspended")); + require(search.isSuccessful(), "search should succeed"); + require(search.getData().size() == 1, "search should find updated reader by filters"); + + ServiceResult> invalidSearch = service.searchReaders( + new ReaderSearchCriteria("", "", "", "missing")); + require(!invalidSearch.isSuccessful(), "invalid search status should fail"); + require(invalidSearch.getErrors().containsKey("status"), "invalid search status should target status field"); + + ServiceResult deactivated = service.deactivateReader(librarian, createdId); + require(deactivated.isSuccessful(), "librarian should deactivate a reader"); + require(dao.findById(createdId).get().getStatus() == ReaderStatus.INACTIVE, + "deactivate should mark the reader inactive"); + + ReaderService failingService = new ReaderServiceImpl(new FailingReaderDao()); + ServiceResult> unavailable = failingService.searchReaders(new ReaderSearchCriteria()); + require(!unavailable.isSuccessful(), "DAO failure should not escape service"); + require(UNAVAILABLE_MESSAGE.equals(unavailable.getMessage()), "search DAO failure should map to safe message"); + + ServiceResult unavailableCreate = failingService.createReader(librarian, + reader(0L, "RD-1007", null, "Unavailable Create", "13800000008", "", ReaderStatus.ACTIVE, 5)); + require(!unavailableCreate.isSuccessful(), "create DAO failure should not escape service"); + require(UNAVAILABLE_MESSAGE.equals(unavailableCreate.getMessage()), + "create DAO failure should map to safe message"); + + ServiceResult unavailableUpdate = failingService.updateReader(librarian, + reader(1L, "RD-1007", null, "Unavailable Update", "13800000009", "", ReaderStatus.ACTIVE, 5)); + require(!unavailableUpdate.isSuccessful(), "update DAO failure should not escape service"); + require(UNAVAILABLE_MESSAGE.equals(unavailableUpdate.getMessage()), + "update DAO failure should map to safe message"); + + ServiceResult unavailableDeactivate = failingService.deactivateReader(librarian, 1L); + require(!unavailableDeactivate.isSuccessful(), "deactivate DAO failure should not escape service"); + require(UNAVAILABLE_MESSAGE.equals(unavailableDeactivate.getMessage()), + "deactivate DAO failure should map to safe message"); + } + + 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_BOOKS, Permission.MANAGE_READERS, Permission.VIEW_CATALOG)); + } + + private static Reader reader(long id, String identifier, Long userId, String fullName, String phone, + String email, ReaderStatus status, int maxBorrowCount) { + Reader reader = new Reader(); + reader.setId(id); + reader.setIdentifier(identifier); + reader.setUserId(userId); + reader.setFullName(fullName); + reader.setPhone(phone); + reader.setEmail(email); + reader.setStatus(status); + reader.setMaxBorrowCount(maxBorrowCount); + return reader; + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + private static final class InMemoryReaderDao implements ReaderDao { + private final Map readers = new LinkedHashMap<>(); + private long nextId = 1L; + + @Override + public List search(ReaderSearchCriteria criteria) { + List matches = new ArrayList<>(); + for (Reader reader : readers.values()) { + if (matches(criteria.getIdentifier(), reader.getIdentifier()) + && matches(criteria.getName(), reader.getFullName()) + && contactMatches(criteria.getContact(), reader) + && (criteria.getStatusCode() == null || criteria.getStatusCode().isEmpty() + || reader.getStatus().getCode().equals(criteria.getStatusCode()))) { + matches.add(copy(reader)); + } + } + return matches; + } + + @Override + public Optional findById(long id) { + return Optional.ofNullable(readers.get(id)).map(this::copy); + } + + @Override + public Optional findByIdentifier(String identifier) { + for (Reader reader : readers.values()) { + if (reader.getIdentifier().equals(identifier)) { + return Optional.of(copy(reader)); + } + } + return Optional.empty(); + } + + @Override + public Optional findByUserId(long userId) { + for (Reader reader : readers.values()) { + if (reader.getUserId() != null && reader.getUserId() == userId) { + return Optional.of(copy(reader)); + } + } + return Optional.empty(); + } + + @Override + public long create(Reader reader) { + long id = nextId++; + Reader stored = copy(reader); + stored.setId(id); + readers.put(id, stored); + return id; + } + + @Override + public boolean update(Reader reader) { + if (!readers.containsKey(reader.getId())) { + return false; + } + readers.put(reader.getId(), copy(reader)); + return true; + } + + @Override + public boolean deactivate(long id) { + Reader reader = readers.get(id); + if (reader == null) { + return false; + } + reader.setStatus(ReaderStatus.INACTIVE); + return true; + } + + private boolean matches(String filter, String value) { + return filter == null || filter.isEmpty() || value.contains(filter); + } + + private boolean contactMatches(String filter, Reader reader) { + if (filter == null || filter.isEmpty()) { + return true; + } + return (reader.getPhone() != null && reader.getPhone().contains(filter)) + || (reader.getEmail() != null && reader.getEmail().contains(filter)); + } + + private Reader copy(Reader source) { + Reader copy = reader(source.getId(), source.getIdentifier(), source.getUserId(), source.getFullName(), + source.getPhone(), source.getEmail(), source.getStatus(), source.getMaxBorrowCount()); + copy.setUsername(source.getUsername()); + return copy; + } + } + + private static final class FailingReaderDao implements ReaderDao { + @Override + public List search(ReaderSearchCriteria criteria) { + throw new DaoException("Simulated search failure", null); + } + + @Override + public Optional findById(long id) { + throw new DaoException("Simulated find failure", null); + } + + @Override + public Optional findByIdentifier(String identifier) { + throw new DaoException("Simulated find failure", null); + } + + @Override + public Optional findByUserId(long userId) { + throw new DaoException("Simulated find failure", null); + } + + @Override + public long create(Reader reader) { + throw new DaoException("Simulated create failure", null); + } + + @Override + public boolean update(Reader reader) { + throw new DaoException("Simulated update failure", null); + } + + @Override + public boolean deactivate(long id) { + throw new DaoException("Simulated deactivate failure", null); + } + } +}