新增书籍表、列表/搜索、管理员/馆员维护入口

This commit is contained in:
Zzzz
2026-04-27 19:49:14 +08:00
parent 8777efa21d
commit 763830f767
28 changed files with 2392 additions and 8 deletions
+113 -5
View File
@@ -24,14 +24,14 @@ Implemented scaffold tables:
- `role_permissions`: role-to-permission mapping. - `role_permissions`: role-to-permission mapping.
- `users`: login accounts for administrator, librarian, and reader roles. - `users`: login accounts for administrator, librarian, and reader roles.
- `system_logs`: key operation logs, backup events, and exception traces. - `system_logs`: key operation logs, backup events, and exception traces.
- `book_categories`: category names and descriptions for catalog grouping.
- `books`: book information, category reference, inventory counts, and catalog
status.
Planned module tables: Planned module tables:
- `books`: book information, inventory count/status, category reference.
- `book_categories`: category names and descriptions.
- `readers`: reader profiles, borrowing eligibility, contact information. - `readers`: reader profiles, borrowing eligibility, contact information.
- `borrow_records`: book-reader borrowing, return, renew, and overdue data. - `borrow_records`: book-reader borrowing, return, renew, and overdue data.
- `system_logs`: key operation logs, backup events, and exception traces.
Record new schema changes in `src/main/resources/db/schema.sql` and update this Record new schema changes in `src/main/resources/db/schema.sql` and update this
spec with exact table names, key columns, and DAO/service contracts. spec with exact table names, key columns, and DAO/service contracts.
@@ -76,8 +76,116 @@ spec with exact table names, key columns, and DAO/service contracts.
- `users.role_code` must reference `roles.code`. - `users.role_code` must reference `roles.code`.
- `role_permissions.role_code` must reference `roles.code`. - `role_permissions.role_code` must reference `roles.code`.
- `role_permissions.permission_code` must reference `permissions.code`. - `role_permissions.permission_code` must reference `permissions.code`.
- Prefer explicit status columns/enums for inventory and borrowing states, then - `books.status` must match `BookStatus` enum codes: `available`,
document the chosen values once code exists. `unavailable`, and `archived`.
## Scenario: Book Catalog And Management Slice
### 1. Scope / Trigger
- Trigger: the first concrete business module introduced catalog search and
basic book management across MySQL, DAO, service, Servlet, and JSP layers.
- Schema path: `src/main/resources/db/schema.sql`.
- JSP paths: `WEB-INF/jsp/books/catalog.jsp`, `books/manage.jsp`, and
`books/form.jsp`.
### 2. Signatures
- DAO signatures: `BookDao.findAllCategories()`,
`BookDao.search(BookSearchCriteria criteria)`, `findById(long id)`,
`findByIdentifier(String identifier)`, `create(Book book)`,
`update(Book book)`, and `delete(long id)`.
- Entity/search signatures: `Book` fields are `id`, `identifier`, `title`,
`author`, `categoryId`, `categoryName`, `totalCopies`, `availableCopies`,
`status`, `createdAt`, and `updatedAt`; `BookSearchCriteria` fields are
`identifier`, `title`, `author`, and nullable `categoryId`.
- Service signatures: `BookService.listCategories()`,
`searchBooks(BookSearchCriteria criteria)`, `findBook(long id)`,
`createBook(AuthenticatedUser actor, Book book)`,
`updateBook(AuthenticatedUser actor, Book book)`, and
`deleteBook(AuthenticatedUser actor, long id)`, all returning
`ServiceResult<T>`.
- Read route: `GET /catalog` with optional `identifier`, `title`, `author`,
and `categoryId` query fields.
- Management routes: `GET /books`, `GET /books/new`, `GET /books/edit?id=...`,
`POST /books`, `POST /books/update`, and `POST /books/delete`.
- Protected permissions: `/catalog` requires `VIEW_CATALOG`; `/books*`
requires `MANAGE_BOOKS`.
- DB signatures:
- `book_categories(id, name, description, created_at, updated_at)`, with
unique key `uk_book_categories_name(name)`.
- `books(id, book_identifier, title, author, category_id, total_copies,
available_copies, status, created_at, updated_at)`, with unique key
`uk_books_identifier(book_identifier)`, indexes on title, author, category,
and status, foreign key `fk_books_category`, and checks for non-negative
copy counts and allowed status values.
### 3. Contracts
- `book_categories.name` is unique and displayed through category selectors.
- `books.book_identifier` is the unique user-facing book ID.
- `books.category_id` references `book_categories.id`.
- `books.total_copies` and `books.available_copies` are non-negative, and
available copies cannot exceed total copies.
- `books.status` stores the Java `BookStatus` code exactly.
- Servlet controllers parse request fields and set JSP attributes such as
`criteria`, `categories`, `books`, `book`, `statuses`, `errors`,
`errorMessage`, and `successMessage`.
- `ServiceResult<T>` is the service-to-controller response contract:
`successful`, nullable `data`, nullable `message`, and field-level
`errors`. Controllers must pass validation errors to JSPs so form/search
redisplay can highlight the exact field, for example `errors.categoryId`.
- JSP pages render JavaBean properties only; they must not call DAOs or embed
SQL.
### 4. Validation & Error Matrix
- Missing identifier, title, author, category, copy counts, or status -> return
to `books/form.jsp` with field errors.
- Negative total or available copies -> return a field error.
- Available copies greater than total copies -> return
`Available copies cannot exceed total copies.`
- Duplicate `book_identifier` -> return a field error on `identifier`.
- 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
`Book service is temporarily unavailable. Please try again later.`
- Successful create/update/delete -> redirect to `/books` with a short flash
message.
### 5. Good/Base/Bad Cases
- Good: a librarian creates `BK-1002`, sees it in `/books`, and readers can
find it from `/catalog` without write controls.
- Base: catalog search with no filters lists available records ordered by
title, author, and identifier.
- Bad: a JSP opens JDBC, builds SQL from request parameters, or renders a stack
trace from a failed DAO call.
### 6. Tests Required
- Run `BookServiceCheck` or equivalent assertions for invalid inventory,
duplicate identifiers, reader write denial, successful librarian CRUD, search,
and DAO failure fallback.
- Run `PermissionPolicyCheck` to confirm readers lack `MANAGE_BOOKS` and retain
`VIEW_CATALOG`.
- 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
books/form.jsp -> JDBC -> INSERT INTO books using request parameters
```
#### Correct
```text
books/form.jsp -> BookManagementServlet -> BookService -> BookDao -> books
```
## Scenario: Login And Permission Scaffold Schema ## Scenario: Login And Permission Scaffold Schema
@@ -0,0 +1,13 @@
{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture overview and checklist for verification."}
{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Verify package layout and layer boundaries."}
{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Verify schema, DAO, search, permission-code, and test expectations."}
{"file": ".trellis/spec/backend/error-handling.md", "reason": "Verify validation, DAO/service failure handling, and safe JSP errors."}
{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Verify logging for key operations and failures."}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Verify backend quality gate and boundary constraints."}
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend architecture overview and checklist for verification."}
{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "Verify JSP and asset placement."}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Verify forms, tables, and navigation follow JSP conventions."}
{"file": ".trellis/spec/frontend/state-management.md", "reason": "Verify server-rendered request/session/form state usage."}
{"file": ".trellis/spec/frontend/type-safety.md", "reason": "Verify JSP request attribute and Servlet validation contracts."}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Verify presentation quality for JSP/CSS changes."}
{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Verify the implementation matches the original book/catalog module requirements."}
@@ -0,0 +1,13 @@
{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture overview and checklist for JSP/Servlet/MySQL work."}
{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Required package layout and layer boundaries for controllers, services, DAOs, entities, filters, and utilities."}
{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "MySQL schema, DAO CRUD, search, permission-code, and test expectations for book/catalog work."}
{"file": ".trellis/spec/backend/error-handling.md", "reason": "Servlet validation, service failure handling, DAO exception behavior, and safe user-facing errors."}
{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Logging conventions for key operations and failures."}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Backend quality gate and layer-boundary constraints."}
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend architecture overview and checklist for JSP/CSS work."}
{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "JSP, fragment, CSS, JS, and image asset placement conventions."}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "JSP form, table, navigation, and reusable component conventions for catalog/management pages."}
{"file": ".trellis/spec/frontend/state-management.md", "reason": "Server-rendered request/session/form state conventions."}
{"file": ".trellis/spec/frontend/type-safety.md", "reason": "JSP request attribute and Servlet validation contracts."}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Presentation quality checks for JSP/CSS changes."}
{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Original project requirements for book management, catalog search, entities, and layered architecture."}
@@ -0,0 +1,97 @@
# brainstorm: 继续完善程序
## Goal
Continue improving the MZH Library Management system by adding the first concrete business slice: book catalog search and basic book management.
## What I already know
* User asked to "继续完善程序" without specifying the target module yet.
* The project is a Java 11 Maven WAR application using JSP, Servlet, Tomcat, MySQL, and JDBC DAOs.
* Current scaffold includes login, logout, authentication filter, authorization filter, role-aware dashboard, and placeholder role home pages.
* Existing roles are administrator, librarian, and reader.
* Existing permissions include user management, book management, reader management, borrowing management, report viewing, system log viewing, catalog viewing, and book borrowing.
* `src/main/resources/db/schema.sql` currently defines role, permission, role-permission, user, and system-log tables, but not book/catalog/reader/borrowing domain tables.
* `src/main/webapp/WEB-INF/jsp/role-home.jsp` is still a generic placeholder page.
* User selected the book catalog / book management slice.
* `Permission.MANAGE_BOOKS` already exists for administrator and librarian users.
* `Permission.VIEW_CATALOG` already exists for administrator, librarian, and reader users.
## Assumptions (temporary)
* The implementation should keep the existing layered structure: JSP/CSS presentation -> Servlet controller -> Service -> DAO -> MySQL.
* The work should remain focused enough to finish in one task with build/test verification.
* Book management should be available to administrators and librarians; readers should have read-only catalog search.
## Open Questions
* None currently blocking. Default scope is catalog list/search plus create/update/delete management for books and categories only as needed to support book records.
## Requirements (evolving)
* Preserve the existing login, role, permission, and dashboard behavior.
* Add book catalog search using the existing Java/JSP/Servlet/JDBC style.
* Add a `books` table and a category representation in `src/main/resources/db/schema.sql`.
* Support searching/listing books by title, author, category, and book identifier.
* Provide administrator/librarian book-management actions for creating, editing, and deleting book records.
* Keep reader-facing catalog access read-only.
* Link the catalog and management pages from the existing dashboard or role workspace.
* Protect write actions with `MANAGE_BOOKS`; allow read-only catalog access with `VIEW_CATALOG`.
* Track inventory fields needed for later borrowing work, such as total copies, available copies, and status.
* Seed enough demo book/category data for local scaffold verification.
* Add or update focused checks/tests for business rules that can be verified without a running Tomcat instance.
* Update user-facing JSP pages and CSS only as needed for the selected workflow.
## Acceptance Criteria (evolving)
* [x] Authenticated users with `VIEW_CATALOG` can reach a catalog page and search by title, author, category, or book identifier.
* [x] Administrators and librarians can reach book management pages and create, edit, and delete book records.
* [x] Readers cannot reach write actions for book management.
* [x] Required form/query validation returns clear user-facing errors.
* [x] Book inventory fields reject invalid values such as negative copy counts or available copies greater than total copies.
* [x] DAO/service failures are handled without exposing internal exceptions to JSPs.
* [x] Maven build or equivalent compile/test checks pass in the local environment.
## 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.
* Borrowing, returning, renewing, overdue handling, and reader profile management.
* Full report/statistics dashboards beyond catalog search.
## Technical Notes
* Current task directory: `.trellis/tasks/04-27-continue-improve-program`.
* Main app entry points inspected:
* `README.md`
* `pom.xml`
* `src/main/webapp/WEB-INF/web.xml`
* `src/main/resources/db/schema.sql`
* `src/main/webapp/WEB-INF/jsp/dashboard.jsp`
* `src/main/webapp/WEB-INF/jsp/role-home.jsp`
* `src/main/java/com/mzh/library/controller/RoleAreaServlet.java`
* `src/main/java/com/mzh/library/filter/AuthorizationFilter.java`
* Existing checks inspected:
* `src/test/java/com/mzh/library/service/AuthServiceCheck.java`
* `src/test/java/com/mzh/library/service/PermissionPolicyCheck.java`
* Book-module implementation should reuse:
* `Permission.MANAGE_BOOKS`
* `Permission.VIEW_CATALOG`
* Existing controller construction style from `LoginServlet` and `RoleAreaServlet`
* Existing DAO error style from `JdbcUserDao`
* Existing service error style from `AuthServiceImpl`
* Existing role policy in `PermissionPolicy`
* Final verification notes:
* `mvn` is not installed in this shell, so WAR packaging could not be run locally.
* Fallback `javac -Xlint:all` compile passed for non-Servlet app layers and check classes.
* `AuthServiceCheck`, `PermissionPolicyCheck`, and `BookServiceCheck` passed.
* `git diff --check` passed.
* JSP scriptlet and JSP/static SQL/JDBC scans passed.
@@ -0,0 +1,26 @@
{
"id": "continue-improve-program",
"name": "continue-improve-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": {}
}
@@ -0,0 +1,98 @@
package com.mzh.library.controller;
import com.mzh.library.dao.impl.JdbcBookDao;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.Book;
import com.mzh.library.entity.BookCategory;
import com.mzh.library.entity.BookSearchCriteria;
import com.mzh.library.entity.Permission;
import com.mzh.library.service.BookService;
import com.mzh.library.service.PermissionPolicy;
import com.mzh.library.service.ServiceResult;
import com.mzh.library.service.impl.BookServiceImpl;
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 BookCatalogServlet extends HttpServlet {
private static final String CATALOG_JSP = "/WEB-INF/jsp/books/catalog.jsp";
private BookService bookService;
private PermissionPolicy permissionPolicy;
@Override
public void init() {
this.bookService = new BookServiceImpl(new JdbcBookDao());
this.permissionPolicy = new PermissionPolicy();
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
BookSearchCriteria criteria = searchCriteria(request);
request.setAttribute("criteria", criteria);
request.setAttribute("canManageBooks", canManageBooks(request));
ServiceResult<List<BookCategory>> categoryResult = bookService.listCategories();
request.setAttribute("categories", categoryResult.isSuccessful()
? categoryResult.getData()
: Collections.emptyList());
if (!categoryResult.isSuccessful()) {
request.setAttribute("errorMessage", categoryResult.getMessage());
}
ServiceResult<List<Book>> searchResult = bookService.searchBooks(criteria);
request.setAttribute("books", searchResult.isSuccessful()
? searchResult.getData()
: Collections.emptyList());
if (!searchResult.isSuccessful()) {
request.setAttribute("errorMessage", searchResult.getMessage());
request.setAttribute("errors", searchResult.getErrors());
}
request.getRequestDispatcher(CATALOG_JSP).forward(request, response);
}
private BookSearchCriteria searchCriteria(HttpServletRequest request) {
return new BookSearchCriteria(
request.getParameter("identifier"),
request.getParameter("title"),
request.getParameter("author"),
optionalLong(request.getParameter("categoryId"))
);
}
private Long optionalLong(String value) {
String trimmed = trim(value);
if (trimmed.isEmpty()) {
return null;
}
try {
return Long.valueOf(trimmed);
} catch (NumberFormatException ex) {
return -1L;
}
}
private boolean canManageBooks(HttpServletRequest request) {
AuthenticatedUser user = currentUser(request);
return user != null && permissionPolicy.allows(user.getRole(), Permission.MANAGE_BOOKS);
}
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 String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,371 @@
package com.mzh.library.controller;
import com.mzh.library.dao.impl.JdbcBookDao;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.Book;
import com.mzh.library.entity.BookCategory;
import com.mzh.library.entity.BookSearchCriteria;
import com.mzh.library.entity.BookStatus;
import com.mzh.library.service.BookService;
import com.mzh.library.service.ServiceResult;
import com.mzh.library.service.impl.BookServiceImpl;
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 BookManagementServlet extends HttpServlet {
private static final String MANAGE_JSP = "/WEB-INF/jsp/books/manage.jsp";
private static final String FORM_JSP = "/WEB-INF/jsp/books/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 BookService bookService;
@Override
public void init() {
this.bookService = new BookServiceImpl(new JdbcBookDao());
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String path = request.getServletPath();
if ("/books/new".equals(path)) {
renderForm(request, response, "Create book", "/books", new Book(), Collections.emptyMap(),
Collections.emptyMap(), null);
return;
}
if ("/books/edit".equals(path)) {
showEditForm(request, response);
return;
}
if (!"/books".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 ("/books".equals(path)) {
createBook(request, response);
return;
}
if ("/books/update".equals(path)) {
updateBook(request, response);
return;
}
if ("/books/delete".equals(path)) {
deleteBook(request, response);
return;
}
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
private void showManagementList(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
BookSearchCriteria criteria = searchCriteria(request);
request.setAttribute("criteria", criteria);
applyFlash(request);
ServiceResult<List<BookCategory>> categoryResult = bookService.listCategories();
request.setAttribute("categories", categoryResult.isSuccessful()
? categoryResult.getData()
: Collections.emptyList());
if (!categoryResult.isSuccessful()) {
request.setAttribute("errorMessage", categoryResult.getMessage());
}
ServiceResult<List<Book>> searchResult = bookService.searchBooks(criteria);
request.setAttribute("books", 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<Optional<Book>> result = bookService.findBook(id);
if (!result.isSuccessful() || !result.getData().isPresent()) {
flashError(request, result.isSuccessful() ? "Book was not found." : result.getMessage());
response.sendRedirect(request.getContextPath() + "/books");
return;
}
renderForm(request, response, "Edit book", "/books/update", result.getData().get(),
Collections.emptyMap(), Collections.emptyMap(), null);
}
private void createBook(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
BookForm form = readBookForm(request, false);
if (!form.getErrors().isEmpty()) {
renderForm(request, response, "Create book", "/books", form.getBook(), form.getValues(),
form.getErrors(), "Please correct the highlighted book fields.");
return;
}
ServiceResult<Long> result = bookService.createBook(currentUser(request), form.getBook());
if (!result.isSuccessful()) {
handleFormFailure(request, response, "Create book", "/books", form, result);
return;
}
flashSuccess(request, result.getMessage());
response.sendRedirect(request.getContextPath() + "/books");
}
private void updateBook(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
BookForm form = readBookForm(request, true);
if (!form.getErrors().isEmpty()) {
renderForm(request, response, "Edit book", "/books/update", form.getBook(), form.getValues(),
form.getErrors(), "Please correct the highlighted book fields.");
return;
}
ServiceResult<Void> result = bookService.updateBook(currentUser(request), form.getBook());
if (!result.isSuccessful()) {
handleFormFailure(request, response, "Edit book", "/books/update", form, result);
return;
}
flashSuccess(request, result.getMessage());
response.sendRedirect(request.getContextPath() + "/books");
}
private void deleteBook(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
long id = requiredLong(request.getParameter("id"), -1L);
ServiceResult<Void> result = bookService.deleteBook(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() + "/books");
}
private void handleFormFailure(HttpServletRequest request, HttpServletResponse response, String title,
String action, BookForm form, ServiceResult<?> result)
throws ServletException, IOException {
if (isPermissionDenied(result)) {
forwardDenied(request, response, result.getMessage());
return;
}
renderForm(request, response, title, action, form.getBook(), form.getValues(), result.getErrors(),
result.getMessage());
}
private void renderForm(HttpServletRequest request, HttpServletResponse response, String title, String action,
Book book, Map<String, String> formValues, Map<String, String> errors, String errorMessage)
throws ServletException, IOException {
ServiceResult<List<BookCategory>> categoryResult = bookService.listCategories();
request.setAttribute("categories", categoryResult.isSuccessful()
? categoryResult.getData()
: Collections.emptyList());
request.setAttribute("statuses", BookStatus.values());
request.setAttribute("formTitle", title);
request.setAttribute("formAction", action);
request.setAttribute("book", book);
request.setAttribute("formValues", formValues);
request.setAttribute("errors", errors);
if (errorMessage != null && !errorMessage.isEmpty()) {
request.setAttribute("errorMessage", errorMessage);
} else if (!categoryResult.isSuccessful()) {
request.setAttribute("errorMessage", categoryResult.getMessage());
}
request.getRequestDispatcher(FORM_JSP).forward(request, response);
}
private BookForm readBookForm(HttpServletRequest request, boolean requireId) {
Map<String, String> values = formValues(request);
Map<String, String> errors = new LinkedHashMap<>();
Book book = new Book();
if (requireId) {
book.setId(parseLong(values.get("id"), "id", "Select a valid book.", errors));
}
book.setIdentifier(values.get("identifier"));
book.setTitle(values.get("title"));
book.setAuthor(values.get("author"));
book.setCategoryId(parseLong(values.get("categoryId"), "categoryId", "Select a category.", errors));
book.setTotalCopies(parseInt(values.get("totalCopies"), "totalCopies", "Enter a valid total copy count.", errors));
book.setAvailableCopies(parseInt(values.get("availableCopies"), "availableCopies",
"Enter a valid available copy count.", errors));
try {
book.setStatus(BookStatus.fromCode(values.get("status")));
} catch (IllegalArgumentException ex) {
errors.put("status", "Select a status.");
}
return new BookForm(book, values, errors);
}
private Map<String, String> formValues(HttpServletRequest request) {
Map<String, String> values = new LinkedHashMap<>();
values.put("id", trim(request.getParameter("id")));
values.put("identifier", trim(request.getParameter("identifier")));
values.put("title", trim(request.getParameter("title")));
values.put("author", trim(request.getParameter("author")));
values.put("categoryId", trim(request.getParameter("categoryId")));
values.put("totalCopies", trim(request.getParameter("totalCopies")));
values.put("availableCopies", trim(request.getParameter("availableCopies")));
values.put("status", trim(request.getParameter("status")));
return values;
}
private BookSearchCriteria searchCriteria(HttpServletRequest request) {
return new BookSearchCriteria(
request.getParameter("identifier"),
request.getParameter("title"),
request.getParameter("author"),
optionalLong(request.getParameter("categoryId"))
);
}
private Long optionalLong(String value) {
String trimmed = trim(value);
if (trimmed.isEmpty()) {
return null;
}
try {
return Long.valueOf(trimmed);
} catch (NumberFormatException ex) {
return -1L;
}
}
private long parseLong(String value, String field, String message, Map<String, String> 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<String, String> 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 books.".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 BookForm {
private final Book book;
private final Map<String, String> values;
private final Map<String, String> errors;
private BookForm(Book book, Map<String, String> values, Map<String, String> errors) {
this.book = book;
this.values = values;
this.errors = errors;
}
private Book getBook() {
return book;
}
private Map<String, String> getValues() {
return values;
}
private Map<String, String> getErrors() {
return errors;
}
}
}
@@ -0,0 +1,24 @@
package com.mzh.library.dao;
import com.mzh.library.entity.Book;
import com.mzh.library.entity.BookCategory;
import com.mzh.library.entity.BookSearchCriteria;
import java.util.List;
import java.util.Optional;
public interface BookDao {
List<BookCategory> findAllCategories();
List<Book> search(BookSearchCriteria criteria);
Optional<Book> findById(long id);
Optional<Book> findByIdentifier(String identifier);
long create(Book book);
boolean update(Book book);
boolean delete(long id);
}
@@ -0,0 +1,224 @@
package com.mzh.library.dao.impl;
import com.mzh.library.dao.BookDao;
import com.mzh.library.entity.Book;
import com.mzh.library.entity.BookCategory;
import com.mzh.library.entity.BookSearchCriteria;
import com.mzh.library.entity.BookStatus;
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 JdbcBookDao implements BookDao {
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_ALL_CATEGORIES = ""
+ "SELECT id, name, description "
+ "FROM book_categories "
+ "ORDER BY name";
private static final String FIND_BY_ID = "SELECT " + BOOK_COLUMNS + BOOK_FROM + "WHERE b.id = ?";
private static final String FIND_BY_IDENTIFIER = "SELECT " + BOOK_COLUMNS + BOOK_FROM
+ "WHERE b.book_identifier = ?";
private static final String CREATE = ""
+ "INSERT INTO books "
+ "(book_identifier, title, author, category_id, total_copies, available_copies, status) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)";
private static final String UPDATE = ""
+ "UPDATE books "
+ "SET book_identifier = ?, title = ?, author = ?, category_id = ?, total_copies = ?, "
+ "available_copies = ?, status = ? "
+ "WHERE id = ?";
private static final String DELETE = "DELETE FROM books WHERE id = ?";
@Override
public List<BookCategory> findAllCategories() {
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(FIND_ALL_CATEGORIES);
ResultSet resultSet = statement.executeQuery()) {
List<BookCategory> categories = new ArrayList<>();
while (resultSet.next()) {
categories.add(mapCategory(resultSet));
}
return categories;
} catch (SQLException ex) {
throw new DaoException("Unable to load book categories", ex);
}
}
@Override
public List<Book> search(BookSearchCriteria criteria) {
List<Object> parameters = new ArrayList<>();
StringBuilder sql = new StringBuilder("SELECT ")
.append(BOOK_COLUMNS)
.append(BOOK_FROM)
.append("WHERE 1 = 1 ");
appendLike(sql, parameters, "b.book_identifier", criteria.getIdentifier());
appendLike(sql, parameters, "b.title", criteria.getTitle());
appendLike(sql, parameters, "b.author", criteria.getAuthor());
if (criteria.getCategoryId() != null) {
sql.append("AND b.category_id = ? ");
parameters.add(criteria.getCategoryId());
}
sql.append("ORDER BY b.title, b.author, b.book_identifier");
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(sql.toString())) {
bind(statement, parameters);
try (ResultSet resultSet = statement.executeQuery()) {
List<Book> books = new ArrayList<>();
while (resultSet.next()) {
books.add(mapBook(resultSet));
}
return books;
}
} catch (SQLException | IllegalArgumentException ex) {
throw new DaoException("Unable to search books", ex);
}
}
@Override
public Optional<Book> 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(mapBook(resultSet)) : Optional.empty();
}
} catch (SQLException | IllegalArgumentException ex) {
throw new DaoException("Unable to load book by id", ex);
}
}
@Override
public Optional<Book> 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(mapBook(resultSet)) : Optional.empty();
}
} catch (SQLException | IllegalArgumentException ex) {
throw new DaoException("Unable to load book by identifier", ex);
}
}
@Override
public long create(Book book) {
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(CREATE, Statement.RETURN_GENERATED_KEYS)) {
bindBook(statement, book);
statement.executeUpdate();
try (ResultSet generatedKeys = statement.getGeneratedKeys()) {
if (generatedKeys.next()) {
return generatedKeys.getLong(1);
}
}
throw new DaoException("Unable to read generated book id", null);
} catch (SQLException ex) {
throw new DaoException("Unable to create book", ex);
}
}
@Override
public boolean update(Book book) {
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(UPDATE)) {
bindBook(statement, book);
statement.setLong(8, book.getId());
return statement.executeUpdate() == 1;
} catch (SQLException ex) {
throw new DaoException("Unable to update book", ex);
}
}
@Override
public boolean delete(long id) {
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(DELETE)) {
statement.setLong(1, id);
return statement.executeUpdate() == 1;
} catch (SQLException ex) {
throw new DaoException("Unable to delete book", 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 bind(PreparedStatement statement, List<Object> parameters) throws SQLException {
for (int i = 0; i < parameters.size(); i++) {
Object value = parameters.get(i);
if (value instanceof Long) {
statement.setLong(i + 1, (Long) value);
} else {
statement.setString(i + 1, value.toString());
}
}
}
private void bindBook(PreparedStatement statement, Book book) throws SQLException {
statement.setString(1, book.getIdentifier());
statement.setString(2, book.getTitle());
statement.setString(3, book.getAuthor());
statement.setLong(4, book.getCategoryId());
statement.setInt(5, book.getTotalCopies());
statement.setInt(6, book.getAvailableCopies());
statement.setString(7, book.getStatus().getCode());
}
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 BookCategory mapCategory(ResultSet resultSet) throws SQLException {
BookCategory category = new BookCategory();
category.setId(resultSet.getLong("id"));
category.setName(resultSet.getString("name"));
category.setDescription(resultSet.getString("description"));
return category;
}
private LocalDateTime toLocalDateTime(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toLocalDateTime();
}
}
@@ -0,0 +1,105 @@
package com.mzh.library.entity;
import java.time.LocalDateTime;
public class Book {
private long id;
private String identifier;
private String title;
private String author;
private long categoryId;
private String categoryName;
private int totalCopies;
private int availableCopies;
private BookStatus status;
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 String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public long getCategoryId() {
return categoryId;
}
public void setCategoryId(long categoryId) {
this.categoryId = categoryId;
}
public String getCategoryName() {
return categoryName;
}
public void setCategoryName(String categoryName) {
this.categoryName = categoryName;
}
public int getTotalCopies() {
return totalCopies;
}
public void setTotalCopies(int totalCopies) {
this.totalCopies = totalCopies;
}
public int getAvailableCopies() {
return availableCopies;
}
public void setAvailableCopies(int availableCopies) {
this.availableCopies = availableCopies;
}
public BookStatus getStatus() {
return status;
}
public void setStatus(BookStatus 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;
}
}
@@ -0,0 +1,31 @@
package com.mzh.library.entity;
public class BookCategory {
private long id;
private String name;
private String description;
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -0,0 +1,54 @@
package com.mzh.library.entity;
public class BookSearchCriteria {
private String identifier;
private String title;
private String author;
private Long categoryId;
public BookSearchCriteria() {
}
public BookSearchCriteria(String identifier, String title, String author, Long categoryId) {
this.identifier = trim(identifier);
this.title = trim(title);
this.author = trim(author);
this.categoryId = categoryId;
}
public String getIdentifier() {
return identifier;
}
public void setIdentifier(String identifier) {
this.identifier = trim(identifier);
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = trim(title);
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = trim(author);
}
public Long getCategoryId() {
return categoryId;
}
public void setCategoryId(Long categoryId) {
this.categoryId = categoryId;
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,40 @@
package com.mzh.library.entity;
import java.util.Locale;
public enum BookStatus {
AVAILABLE("available", "Available"),
UNAVAILABLE("unavailable", "Unavailable"),
ARCHIVED("archived", "Archived");
private final String code;
private final String displayName;
BookStatus(String code, String displayName) {
this.code = code;
this.displayName = displayName;
}
public String getCode() {
return code;
}
public String getDisplayName() {
return displayName;
}
public static BookStatus fromCode(String code) {
if (code == null || code.trim().isEmpty()) {
throw new IllegalArgumentException("Book status is required");
}
String normalized = code.trim().toLowerCase(Locale.ROOT);
for (BookStatus status : values()) {
if (status.code.equals(normalized)) {
return status;
}
}
throw new IllegalArgumentException("Unsupported book status: " + code);
}
}
@@ -1,6 +1,8 @@
package com.mzh.library.exception; package com.mzh.library.exception;
public class DaoException extends RuntimeException { public class DaoException extends RuntimeException {
private static final long serialVersionUID = 1L;
public DaoException(String message, Throwable cause) { public DaoException(String message, Throwable cause) {
super(message, cause); super(message, cause);
} }
@@ -25,6 +25,8 @@ public class AuthorizationFilter implements Filter {
private static final Logger LOGGER = Logger.getLogger(AuthorizationFilter.class.getName()); 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 String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
private static final List<PathRule> RULES = Arrays.asList( private static final List<PathRule> RULES = Arrays.asList(
new PathRule("/books", Permission.MANAGE_BOOKS),
new PathRule("/catalog", Permission.VIEW_CATALOG),
new PathRule("/admin", Permission.MANAGE_USERS), new PathRule("/admin", Permission.MANAGE_USERS),
new PathRule("/librarian", Permission.MANAGE_BORROWING), new PathRule("/librarian", Permission.MANAGE_BORROWING),
new PathRule("/reader", Permission.VIEW_CATALOG) new PathRule("/reader", Permission.VIEW_CATALOG)
@@ -0,0 +1,23 @@
package com.mzh.library.service;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.Book;
import com.mzh.library.entity.BookCategory;
import com.mzh.library.entity.BookSearchCriteria;
import java.util.List;
import java.util.Optional;
public interface BookService {
ServiceResult<List<BookCategory>> listCategories();
ServiceResult<List<Book>> searchBooks(BookSearchCriteria criteria);
ServiceResult<Optional<Book>> findBook(long id);
ServiceResult<Long> createBook(AuthenticatedUser actor, Book book);
ServiceResult<Void> updateBook(AuthenticatedUser actor, Book book);
ServiceResult<Void> deleteBook(AuthenticatedUser actor, long id);
}
@@ -0,0 +1,55 @@
package com.mzh.library.service;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public class ServiceResult<T> {
private final boolean successful;
private final T data;
private final String message;
private final Map<String, String> errors;
private ServiceResult(boolean successful, T data, String message, Map<String, String> errors) {
this.successful = successful;
this.data = data;
this.message = message;
this.errors = Collections.unmodifiableMap(new LinkedHashMap<>(errors));
}
public static <T> ServiceResult<T> success(T data) {
return new ServiceResult<>(true, data, null, Collections.emptyMap());
}
public static <T> ServiceResult<T> success(T data, String message) {
return new ServiceResult<>(true, data, message, Collections.emptyMap());
}
public static <T> ServiceResult<T> failure(String message) {
return new ServiceResult<>(false, null, message, Collections.emptyMap());
}
public static <T> ServiceResult<T> validationFailure(String message, Map<String, String> errors) {
return new ServiceResult<>(false, null, message, errors);
}
public boolean isSuccessful() {
return successful;
}
public T getData() {
return data;
}
public String getMessage() {
return message;
}
public Map<String, String> getErrors() {
return errors;
}
public boolean hasErrors() {
return !errors.isEmpty();
}
}
@@ -0,0 +1,218 @@
package com.mzh.library.service.impl;
import com.mzh.library.dao.BookDao;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.Book;
import com.mzh.library.entity.BookCategory;
import com.mzh.library.entity.BookSearchCriteria;
import com.mzh.library.entity.Permission;
import com.mzh.library.exception.DaoException;
import com.mzh.library.service.BookService;
import com.mzh.library.service.PermissionPolicy;
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.logging.Level;
import java.util.logging.Logger;
public class BookServiceImpl implements BookService {
private static final Logger LOGGER = Logger.getLogger(BookServiceImpl.class.getName());
private static final String UNAVAILABLE_MESSAGE =
"Book service is temporarily unavailable. Please try again later.";
private static final String VALIDATION_MESSAGE = "Please correct the highlighted book fields.";
private static final String DENIED_MESSAGE = "You do not have permission to manage books.";
private final BookDao bookDao;
private final PermissionPolicy permissionPolicy;
public BookServiceImpl(BookDao bookDao) {
this(bookDao, new PermissionPolicy());
}
public BookServiceImpl(BookDao bookDao, PermissionPolicy permissionPolicy) {
this.bookDao = bookDao;
this.permissionPolicy = permissionPolicy;
}
@Override
public ServiceResult<List<BookCategory>> listCategories() {
try {
return ServiceResult.success(bookDao.findAllCategories());
} catch (DaoException ex) {
LOGGER.log(Level.SEVERE, "Unable to list book categories", ex);
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
}
}
@Override
public ServiceResult<List<Book>> searchBooks(BookSearchCriteria criteria) {
BookSearchCriteria normalized = criteria == null ? new BookSearchCriteria() : criteria;
if (normalized.getCategoryId() != null && normalized.getCategoryId() <= 0) {
Map<String, String> errors = new LinkedHashMap<>();
errors.put("categoryId", "Select a valid category.");
return ServiceResult.validationFailure("Please correct the catalog search filters.", errors);
}
try {
return ServiceResult.success(bookDao.search(normalized));
} catch (DaoException ex) {
LOGGER.log(Level.SEVERE, "Unable to search books", ex);
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
}
}
@Override
public ServiceResult<Optional<Book>> findBook(long id) {
if (id <= 0) {
return ServiceResult.failure("Select a valid book.");
}
try {
return ServiceResult.success(bookDao.findById(id));
} catch (DaoException ex) {
LOGGER.log(Level.SEVERE, "Unable to load book id=" + id, ex);
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
}
}
@Override
public ServiceResult<Long> createBook(AuthenticatedUser actor, Book book) {
if (!canManageBooks(actor)) {
return ServiceResult.failure(DENIED_MESSAGE);
}
normalize(book);
Map<String, String> errors = validate(book, false);
if (!errors.isEmpty()) {
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
}
try {
if (bookDao.findByIdentifier(book.getIdentifier()).isPresent()) {
errors.put("identifier", "Book identifier is already in use.");
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
}
long id = bookDao.create(book);
LOGGER.info("Created book id=" + id + " actorId=" + actor.getId());
return ServiceResult.success(id, "Book created.");
} catch (DaoException ex) {
LOGGER.log(Level.SEVERE, "Unable to create book actorId=" + actor.getId(), ex);
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
}
}
@Override
public ServiceResult<Void> updateBook(AuthenticatedUser actor, Book book) {
if (!canManageBooks(actor)) {
return ServiceResult.failure(DENIED_MESSAGE);
}
normalize(book);
Map<String, String> errors = validate(book, true);
if (!errors.isEmpty()) {
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
}
try {
Optional<Book> existingWithIdentifier = bookDao.findByIdentifier(book.getIdentifier());
if (existingWithIdentifier.isPresent() && existingWithIdentifier.get().getId() != book.getId()) {
errors.put("identifier", "Book identifier is already in use.");
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
}
if (!bookDao.update(book)) {
return ServiceResult.failure("Book was not found.");
}
LOGGER.info("Updated book id=" + book.getId() + " actorId=" + actor.getId());
return ServiceResult.success(null, "Book updated.");
} catch (DaoException ex) {
LOGGER.log(Level.SEVERE, "Unable to update book id=" + book.getId() + " actorId=" + actor.getId(), ex);
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
}
}
@Override
public ServiceResult<Void> deleteBook(AuthenticatedUser actor, long id) {
if (!canManageBooks(actor)) {
return ServiceResult.failure(DENIED_MESSAGE);
}
if (id <= 0) {
return ServiceResult.failure("Select a valid book.");
}
try {
if (!bookDao.delete(id)) {
return ServiceResult.failure("Book was not found.");
}
LOGGER.info("Deleted book id=" + id + " actorId=" + actor.getId());
return ServiceResult.success(null, "Book deleted.");
} catch (DaoException ex) {
LOGGER.log(Level.SEVERE, "Unable to delete book id=" + id + " actorId=" + actor.getId(), ex);
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
}
}
private boolean canManageBooks(AuthenticatedUser actor) {
return actor != null && permissionPolicy.allows(actor.getRole(), Permission.MANAGE_BOOKS);
}
private void normalize(Book book) {
if (book == null) {
return;
}
book.setIdentifier(trim(book.getIdentifier()));
book.setTitle(trim(book.getTitle()));
book.setAuthor(trim(book.getAuthor()));
}
private Map<String, String> validate(Book book, boolean requireId) {
Map<String, String> errors = new LinkedHashMap<>();
if (book == null) {
errors.put("book", "Book details are required.");
return errors;
}
if (requireId && book.getId() <= 0) {
errors.put("id", "Select a valid book.");
}
requireLength(errors, "identifier", book.getIdentifier(), "Book identifier", 64);
requireLength(errors, "title", book.getTitle(), "Title", 200);
requireLength(errors, "author", book.getAuthor(), "Author", 120);
if (book.getCategoryId() <= 0) {
errors.put("categoryId", "Select a category.");
}
if (book.getTotalCopies() < 0) {
errors.put("totalCopies", "Total copies cannot be negative.");
}
if (book.getAvailableCopies() < 0) {
errors.put("availableCopies", "Available copies cannot be negative.");
}
if (book.getAvailableCopies() > book.getTotalCopies()) {
errors.put("availableCopies", "Available copies cannot exceed total copies.");
}
if (book.getStatus() == null) {
errors.put("status", "Select a status.");
}
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 String trim(String value) {
return value == null ? "" : value.trim();
}
}
+60
View File
@@ -57,6 +57,41 @@ CREATE TABLE IF NOT EXISTS system_logs (
KEY idx_system_logs_created_at (created_at) KEY idx_system_logs_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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,
description VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_book_categories_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS books (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
book_identifier VARCHAR(64) NOT NULL,
title VARCHAR(200) NOT NULL,
author VARCHAR(120) NOT NULL,
category_id BIGINT NOT NULL,
total_copies INT NOT NULL DEFAULT 0,
available_copies INT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'available',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_books_identifier (book_identifier),
KEY idx_books_title (title),
KEY idx_books_author (author),
KEY idx_books_category_id (category_id),
KEY idx_books_status (status),
CONSTRAINT fk_books_category
FOREIGN KEY (category_id) REFERENCES book_categories (id),
CONSTRAINT chk_books_total_copies
CHECK (total_copies >= 0),
CONSTRAINT chk_books_available_copies
CHECK (available_copies >= 0 AND available_copies <= total_copies),
CONSTRAINT chk_books_status
CHECK (status IN ('available', 'unavailable', 'archived'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO roles (code, name, description) VALUES INSERT INTO roles (code, name, description) VALUES
('administrator', 'Administrator', 'Full system administration role'), ('administrator', 'Administrator', 'Full system administration role'),
('librarian', 'Librarian', 'Library operation and borrowing management role'), ('librarian', 'Librarian', 'Library operation and borrowing management role'),
@@ -101,3 +136,28 @@ INSERT IGNORE INTO users (username, password_hash, display_name, role_code, acti
('admin', 'pbkdf2_sha256$60000$bXpoLWFkbWluLWRlbW8tc2FsdA==$RwBCvhf3Wsc0jemnHlir4mdNZF4ZhHjrfHx/b1Bera0=', 'System Administrator', 'administrator', 1), ('admin', 'pbkdf2_sha256$60000$bXpoLWFkbWluLWRlbW8tc2FsdA==$RwBCvhf3Wsc0jemnHlir4mdNZF4ZhHjrfHx/b1Bera0=', 'System Administrator', 'administrator', 1),
('librarian', 'pbkdf2_sha256$60000$bXpoLWxpYnJhcmlhbi1kZW1vLXNhbHQ=$StIdJGDRIiF4aCr+qKuwvob5sL3+6j1caF2sQNqFi78=', 'Library Staff', 'librarian', 1), ('librarian', 'pbkdf2_sha256$60000$bXpoLWxpYnJhcmlhbi1kZW1vLXNhbHQ=$StIdJGDRIiF4aCr+qKuwvob5sL3+6j1caF2sQNqFi78=', 'Library Staff', 'librarian', 1),
('reader', 'pbkdf2_sha256$60000$bXpoLXJlYWRlci1kZW1vLXNhbHQ=$iaiZPGhaIQ+2R2o9UQRj6wsrmYSJ4efqS3jCzM/XU7g=', 'Demo Reader', 'reader', 1); ('reader', 'pbkdf2_sha256$60000$bXpoLXJlYWRlci1kZW1vLXNhbHQ=$iaiZPGhaIQ+2R2o9UQRj6wsrmYSJ4efqS3jCzM/XU7g=', 'Demo Reader', 'reader', 1);
INSERT INTO book_categories (name, description) VALUES
('Computer Science', 'Programming, software engineering, and systems books'),
('Literature', 'Classic and modern literature'),
('History', 'World and regional history'),
('Science', 'Natural science and popular science')
ON DUPLICATE KEY UPDATE
description = VALUES(description);
INSERT 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',
(SELECT id FROM book_categories WHERE name = 'Computer Science'), 4, 2, 'available'),
('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);
@@ -0,0 +1,109 @@
<%@ 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>Catalog - 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="catalog-title">
<p class="eyebrow">Catalog</p>
<h1 id="catalog-title">Book catalog</h1>
<p>Search the library collection by identifier, title, author, or category.</p>
</section>
<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="Catalog search">
<form class="search-form" action="${pageContext.request.contextPath}/catalog" method="get">
<div class="search-field">
<label for="identifier">Book ID</label>
<input id="identifier" name="identifier" type="text" value="${fn:escapeXml(criteria.identifier)}">
</div>
<div class="search-field">
<label for="title">Title</label>
<input id="title" name="title" type="text" value="${fn:escapeXml(criteria.title)}">
</div>
<div class="search-field">
<label for="author">Author</label>
<input id="author" name="author" type="text" value="${fn:escapeXml(criteria.author)}">
</div>
<div class="search-field">
<label for="categoryId">Category</label>
<select id="categoryId" name="categoryId">
<option value="">All categories</option>
<c:forEach var="category" items="${categories}">
<option value="${category.id}" <c:if test="${criteria.categoryId == category.id}">selected</c:if>>
<c:out value="${category.name}" />
</option>
</c:forEach>
</select>
<c:if test="${not empty errors.categoryId}">
<span class="field-error"><c:out value="${errors.categoryId}" /></span>
</c:if>
</div>
<button class="button button-primary" type="submit">Search</button>
<a class="button button-secondary" href="${pageContext.request.contextPath}/catalog">Clear</a>
<c:if test="${canManageBooks}">
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Manage books</a>
</c:if>
</form>
</section>
<section class="table-panel" aria-labelledby="catalog-results-title">
<h2 id="catalog-results-title">Results</h2>
<c:choose>
<c:when test="${empty books}">
<p class="empty-state">No books match the current filters.</p>
</c:when>
<c:otherwise>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th scope="col">Book ID</th>
<th scope="col">Title</th>
<th scope="col">Author</th>
<th scope="col">Category</th>
<th scope="col">Copies</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<c:forEach var="book" items="${books}">
<tr>
<td><c:out value="${book.identifier}" /></td>
<td><c:out value="${book.title}" /></td>
<td><c:out value="${book.author}" /></td>
<td><c:out value="${book.categoryName}" /></td>
<td><c:out value="${book.availableCopies}" /> / <c:out value="${book.totalCopies}" /></td>
<td>
<span class="status-pill status-${book.status.code}">
<c:out value="${book.status.displayName}" />
</span>
</td>
</tr>
</c:forEach>
</tbody>
</table>
</div>
</c:otherwise>
</c:choose>
</section>
</main>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
<%@ 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><c:out value="${formTitle}" /> - 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="book-form-title">
<p class="eyebrow">Book Management</p>
<h1 id="book-form-title"><c:out value="${formTitle}" /></h1>
<c:if test="${not empty errorMessage}">
<div class="message message-error" role="alert">
<c:out value="${errorMessage}" />
</div>
</c:if>
<c:set var="hasFormValues" value="${not empty formValues}" />
<c:set var="identifierValue" value="${hasFormValues ? formValues.identifier : book.identifier}" />
<c:set var="titleValue" value="${hasFormValues ? formValues.title : book.title}" />
<c:set var="authorValue" value="${hasFormValues ? formValues.author : book.author}" />
<c:set var="categoryValue" value="${book.categoryId}" />
<c:set var="totalCopiesValue" value="${hasFormValues ? formValues.totalCopies : book.totalCopies}" />
<c:set var="availableCopiesValue" value="${hasFormValues ? formValues.availableCopies : book.availableCopies}" />
<c:set var="statusValue" value="${hasFormValues ? formValues.status : book.status.code}" />
<form class="book-form" action="${pageContext.request.contextPath}${formAction}" method="post" novalidate>
<c:if test="${book.id > 0}">
<input type="hidden" name="id" value="${book.id}">
</c:if>
<div class="form-grid">
<div class="form-field">
<label for="identifier">Book ID</label>
<input id="identifier" name="identifier" type="text" value="${fn:escapeXml(identifierValue)}" required>
<c:if test="${not empty errors.identifier}">
<span class="field-error"><c:out value="${errors.identifier}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="title">Title</label>
<input id="title" name="title" type="text" value="${fn:escapeXml(titleValue)}" required>
<c:if test="${not empty errors.title}">
<span class="field-error"><c:out value="${errors.title}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="author">Author</label>
<input id="author" name="author" type="text" value="${fn:escapeXml(authorValue)}" required>
<c:if test="${not empty errors.author}">
<span class="field-error"><c:out value="${errors.author}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="categoryId">Category</label>
<select id="categoryId" name="categoryId" required>
<option value="">Select category</option>
<c:forEach var="category" items="${categories}">
<option value="${category.id}" <c:if test="${categoryValue == category.id}">selected</c:if>>
<c:out value="${category.name}" />
</option>
</c:forEach>
</select>
<c:if test="${not empty errors.categoryId}">
<span class="field-error"><c:out value="${errors.categoryId}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="totalCopies">Total copies</label>
<input id="totalCopies" name="totalCopies" type="number" min="0" value="${fn:escapeXml(totalCopiesValue)}" required>
<c:if test="${not empty errors.totalCopies}">
<span class="field-error"><c:out value="${errors.totalCopies}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="availableCopies">Available copies</label>
<input id="availableCopies" name="availableCopies" type="number" min="0" value="${fn:escapeXml(availableCopiesValue)}" required>
<c:if test="${not empty errors.availableCopies}">
<span class="field-error"><c:out value="${errors.availableCopies}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="status">Status</label>
<select id="status" name="status" required>
<option value="">Select status</option>
<c:forEach var="status" items="${statuses}">
<option value="${status.code}" <c:if test="${statusValue == status.code}">selected</c:if>>
<c:out value="${status.displayName}" />
</option>
</c:forEach>
</select>
<c:if test="${not empty errors.status}">
<span class="field-error"><c:out value="${errors.status}" /></span>
</c:if>
</div>
</div>
<div class="form-actions">
<button class="button button-primary" type="submit">Save</button>
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Cancel</a>
</div>
</form>
</section>
</main>
</body>
</html>
@@ -0,0 +1,126 @@
<%@ 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>Manage Books - 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="manage-title">
<p class="eyebrow">Book Management</p>
<h1 id="manage-title">Manage books</h1>
<p>Create, update, delete, and review inventory for catalog records.</p>
<a class="button button-primary" href="${pageContext.request.contextPath}/books/new">New book</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="Book management search">
<form class="search-form" action="${pageContext.request.contextPath}/books" method="get">
<div class="search-field">
<label for="identifier">Book ID</label>
<input id="identifier" name="identifier" type="text" value="${fn:escapeXml(criteria.identifier)}">
</div>
<div class="search-field">
<label for="title">Title</label>
<input id="title" name="title" type="text" value="${fn:escapeXml(criteria.title)}">
</div>
<div class="search-field">
<label for="author">Author</label>
<input id="author" name="author" type="text" value="${fn:escapeXml(criteria.author)}">
</div>
<div class="search-field">
<label for="categoryId">Category</label>
<select id="categoryId" name="categoryId">
<option value="">All categories</option>
<c:forEach var="category" items="${categories}">
<option value="${category.id}" <c:if test="${criteria.categoryId == category.id}">selected</c:if>>
<c:out value="${category.name}" />
</option>
</c:forEach>
</select>
<c:if test="${not empty errors.categoryId}">
<span class="field-error"><c:out value="${errors.categoryId}" /></span>
</c:if>
</div>
<button class="button button-primary" type="submit">Search</button>
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Clear</a>
<a class="button button-secondary" href="${pageContext.request.contextPath}/catalog">View catalog</a>
</form>
</section>
<section class="table-panel" aria-labelledby="management-results-title">
<h2 id="management-results-title">Book records</h2>
<c:choose>
<c:when test="${empty books}">
<p class="empty-state">No book records match the current filters.</p>
</c:when>
<c:otherwise>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th scope="col">Book ID</th>
<th scope="col">Title</th>
<th scope="col">Author</th>
<th scope="col">Category</th>
<th scope="col">Copies</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<c:forEach var="book" items="${books}">
<tr>
<td><c:out value="${book.identifier}" /></td>
<td><c:out value="${book.title}" /></td>
<td><c:out value="${book.author}" /></td>
<td><c:out value="${book.categoryName}" /></td>
<td><c:out value="${book.availableCopies}" /> / <c:out value="${book.totalCopies}" /></td>
<td>
<span class="status-pill status-${book.status.code}">
<c:out value="${book.status.displayName}" />
</span>
</td>
<td>
<div class="table-actions">
<a class="button button-secondary"
href="${pageContext.request.contextPath}/books/edit?id=${book.id}">Edit</a>
<form action="${pageContext.request.contextPath}/books/delete"
method="post"
onsubmit="return confirm('Delete this book record?');">
<input type="hidden" name="id" value="${book.id}">
<button class="button button-danger" type="submit">Delete</button>
</form>
</div>
</td>
</tr>
</c:forEach>
</tbody>
</table>
</div>
</c:otherwise>
</c:choose>
</section>
</main>
</body>
</html>
@@ -4,11 +4,13 @@
<c:if test="${not empty sessionScope.authenticatedUser}"> <c:if test="${not empty sessionScope.authenticatedUser}">
<nav class="top-nav" aria-label="Primary"> <nav class="top-nav" aria-label="Primary">
<a href="${pageContext.request.contextPath}/dashboard">Dashboard</a> <a href="${pageContext.request.contextPath}/dashboard">Dashboard</a>
<a href="${pageContext.request.contextPath}/catalog">Catalog</a>
<c:if test="${sessionScope.userRole == 'administrator'}"> <c:if test="${sessionScope.userRole == 'administrator'}">
<a href="${pageContext.request.contextPath}/admin/home">Admin</a> <a href="${pageContext.request.contextPath}/admin/home">Admin</a>
</c:if> </c:if>
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}"> <c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
<a href="${pageContext.request.contextPath}/librarian/home">Librarian</a> <a href="${pageContext.request.contextPath}/librarian/home">Librarian</a>
<a href="${pageContext.request.contextPath}/books">Books</a>
</c:if> </c:if>
<a href="${pageContext.request.contextPath}/reader/home">Reader</a> <a href="${pageContext.request.contextPath}/reader/home">Reader</a>
<span class="user-pill"> <span class="user-pill">
+13 -1
View File
@@ -34,11 +34,23 @@
<p>Book, reader, borrowing, return, renewal, and overdue entry point.</p> <p>Book, reader, borrowing, return, renewal, and overdue entry point.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/librarian/home">Open</a> <a class="button button-secondary" href="${pageContext.request.contextPath}/librarian/home">Open</a>
</article> </article>
<article class="workspace-card">
<h2>Book Management</h2>
<p>Create, update, delete, and review book inventory records.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Open</a>
</article>
</c:if> </c:if>
<article class="workspace-card">
<h2>Book Catalog</h2>
<p>Search books by title, author, category, or book identifier.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/catalog">Search</a>
</article>
<article class="workspace-card"> <article class="workspace-card">
<h2>Reader Center</h2> <h2>Reader Center</h2>
<p>Catalog search and reader self-service entry point.</p> <p>Reader self-service entry point.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/reader/home">Open</a> <a class="button button-secondary" href="${pageContext.request.contextPath}/reader/home">Open</a>
</article> </article>
</section> </section>
+16
View File
@@ -19,6 +19,22 @@
<p><c:out value="${areaSummary}" /></p> <p><c:out value="${areaSummary}" /></p>
<a class="button button-primary" href="${pageContext.request.contextPath}/dashboard">Back to dashboard</a> <a class="button button-primary" href="${pageContext.request.contextPath}/dashboard">Back to dashboard</a>
</section> </section>
<section class="card-grid role-actions" aria-label="Workspace actions">
<article class="workspace-card">
<h2>Book Catalog</h2>
<p>Search available collection records by title, author, category, or book identifier.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/catalog">Search catalog</a>
</article>
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
<article class="workspace-card">
<h2>Book Management</h2>
<p>Create, update, delete, and review inventory fields for book records.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Manage books</a>
</article>
</c:if>
</section>
</main> </main>
</body> </body>
</html> </html>
+22
View File
@@ -75,6 +75,28 @@
<url-pattern>/reader/home</url-pattern> <url-pattern>/reader/home</url-pattern>
</servlet-mapping> </servlet-mapping>
<servlet>
<servlet-name>BookCatalogServlet</servlet-name>
<servlet-class>com.mzh.library.controller.BookCatalogServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>BookCatalogServlet</servlet-name>
<url-pattern>/catalog</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>BookManagementServlet</servlet-name>
<servlet-class>com.mzh.library.controller.BookManagementServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>BookManagementServlet</servlet-name>
<url-pattern>/books</url-pattern>
<url-pattern>/books/new</url-pattern>
<url-pattern>/books/edit</url-pattern>
<url-pattern>/books/update</url-pattern>
<url-pattern>/books/delete</url-pattern>
</servlet-mapping>
<servlet> <servlet>
<servlet-name>UnauthorizedServlet</servlet-name> <servlet-name>UnauthorizedServlet</servlet-name>
<servlet-class>com.mzh.library.controller.UnauthorizedServlet</servlet-class> <servlet-class>com.mzh.library.controller.UnauthorizedServlet</servlet-class>
+197 -2
View File
@@ -8,6 +8,8 @@
--color-primary: #256f6c; --color-primary: #256f6c;
--color-primary-strong: #1b5654; --color-primary-strong: #1b5654;
--color-accent: #b54238; --color-accent: #b54238;
--color-success: #2f6f3e;
--color-warning: #8a5a00;
--shadow-panel: 0 18px 45px rgba(28, 39, 49, 0.12); --shadow-panel: 0 18px 45px rgba(28, 39, 49, 0.12);
} }
@@ -91,7 +93,10 @@ a {
.login-panel, .login-panel,
.notice-panel, .notice-panel,
.dashboard-hero, .dashboard-hero,
.workspace-card { .workspace-card,
.toolbar-panel,
.table-panel,
.form-panel {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
background: var(--color-panel); background: var(--color-panel);
@@ -184,6 +189,15 @@ h2 {
background: #ffffff; background: #ffffff;
} }
.button-danger {
color: #ffffff;
background: var(--color-accent);
}
.button-danger:hover {
background: #8f3028;
}
.message { .message {
margin-bottom: 16px; margin-bottom: 16px;
padding: 10px 12px; padding: 10px 12px;
@@ -197,6 +211,12 @@ h2 {
background: #fff0ee; background: #fff0ee;
} }
.message-success {
color: #1f572e;
border: 1px solid rgba(47, 111, 62, 0.3);
background: #effaf1;
}
.page-shell { .page-shell {
padding: 36px 0 56px; padding: 36px 0 56px;
} }
@@ -239,6 +259,173 @@ h2 {
padding: 28px; padding: 28px;
} }
.role-actions {
margin-top: 24px;
}
.catalog-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
flex-wrap: wrap;
}
.catalog-hero .button {
margin-top: 0;
}
.toolbar-panel,
.table-panel,
.form-panel {
padding: 24px;
margin-bottom: 24px;
}
.search-form {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr)) auto auto auto;
gap: 10px;
align-items: end;
}
.search-field {
display: grid;
gap: 6px;
}
.search-form label {
color: var(--color-muted);
font-size: 13px;
font-weight: 700;
}
.search-form input,
.search-form select,
.book-form input,
.book-form select {
width: 100%;
min-height: 42px;
padding: 9px 11px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: #ffffff;
font: inherit;
}
.search-form input:focus,
.search-form select:focus,
.book-form input:focus,
.book-form select:focus {
outline: 3px solid rgba(37, 111, 108, 0.18);
border-color: var(--color-primary);
}
.table-scroll {
width: 100%;
overflow-x: auto;
}
.data-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
font-size: 14px;
}
.data-table th,
.data-table td {
padding: 12px 10px;
text-align: left;
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
.data-table th {
color: var(--color-muted);
font-size: 13px;
font-weight: 700;
background: #f8fafc;
}
.empty-state {
margin-bottom: 0;
color: var(--color-muted);
}
.status-pill {
display: inline-flex;
min-height: 28px;
align-items: center;
padding: 4px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.status-available {
color: var(--color-success);
background: #edf8ef;
}
.status-unavailable {
color: var(--color-warning);
background: #fff7e5;
}
.status-archived {
color: var(--color-muted);
background: #eef1f5;
}
.table-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.table-actions form {
margin: 0;
}
.form-panel {
max-width: 860px;
}
.book-form {
display: grid;
gap: 20px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.form-field {
display: grid;
gap: 6px;
}
.form-field label {
color: var(--color-muted);
font-size: 14px;
font-weight: 700;
}
.field-error {
color: #7a211a;
font-size: 13px;
}
.form-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.app-header { .app-header {
align-items: flex-start; align-items: flex-start;
@@ -257,7 +444,15 @@ h2 {
.login-panel, .login-panel,
.notice-panel, .notice-panel,
.dashboard-hero, .dashboard-hero,
.workspace-card { .workspace-card,
.toolbar-panel,
.table-panel,
.form-panel {
box-shadow: none; box-shadow: none;
} }
.search-form,
.form-grid {
grid-template-columns: 1fr;
}
} }
@@ -0,0 +1,219 @@
package com.mzh.library.service;
import com.mzh.library.dao.BookDao;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.Book;
import com.mzh.library.entity.BookCategory;
import com.mzh.library.entity.BookSearchCriteria;
import com.mzh.library.entity.BookStatus;
import com.mzh.library.entity.Permission;
import com.mzh.library.entity.Role;
import com.mzh.library.exception.DaoException;
import com.mzh.library.service.impl.BookServiceImpl;
import java.util.ArrayList;
import java.util.Collections;
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 BookServiceCheck {
private static final String UNAVAILABLE_MESSAGE =
"Book service is temporarily unavailable. Please try again later.";
private BookServiceCheck() {
}
public static void main(String[] args) {
Logger.getLogger(BookServiceImpl.class.getName()).setLevel(Level.OFF);
InMemoryBookDao dao = new InMemoryBookDao();
BookService service = new BookServiceImpl(dao);
AuthenticatedUser librarian = user(10L, Role.LIBRARIAN);
AuthenticatedUser reader = user(20L, Role.READER);
ServiceResult<Long> invalidInventory = service.createBook(librarian,
book(0L, "BK-1000", "Invalid Copies", "Test Author", 1L, 2, 3, BookStatus.AVAILABLE));
require(!invalidInventory.isSuccessful(), "invalid inventory should fail");
require(invalidInventory.getErrors().containsKey("availableCopies"),
"available copies greater than total should be rejected");
ServiceResult<Long> denied = service.createBook(reader,
book(0L, "BK-1001", "Reader Write", "Test Author", 1L, 1, 1, BookStatus.AVAILABLE));
require(!denied.isSuccessful(), "reader write should fail");
require("You do not have permission to manage books.".equals(denied.getMessage()),
"reader write should return permission message");
ServiceResult<Long> created = service.createBook(librarian,
book(0L, "BK-1002", "Service Test", "Test Author", 1L, 2, 1, BookStatus.AVAILABLE));
require(created.isSuccessful(), "librarian should create a valid book");
long createdId = created.getData();
ServiceResult<Long> duplicate = service.createBook(librarian,
book(0L, "BK-1002", "Duplicate", "Test Author", 1L, 1, 1, BookStatus.AVAILABLE));
require(!duplicate.isSuccessful(), "duplicate identifier should fail");
require(duplicate.getErrors().containsKey("identifier"), "duplicate should target identifier field");
ServiceResult<Void> updated = service.updateBook(librarian,
book(createdId, "BK-1003", "Service Test Updated", "Test Author", 1L, 3, 3, BookStatus.AVAILABLE));
require(updated.isSuccessful(), "librarian should update a valid book");
require(dao.findById(createdId).get().getAvailableCopies() == 3, "update should persist available copies");
ServiceResult<List<Book>> search = service.searchBooks(new BookSearchCriteria("BK-1003", "", "", null));
require(search.isSuccessful(), "search should succeed");
require(search.getData().size() == 1, "search should find updated identifier");
ServiceResult<Void> deleted = service.deleteBook(librarian, createdId);
require(deleted.isSuccessful(), "librarian should delete a book");
require(!dao.findById(createdId).isPresent(), "delete should remove the record");
BookService failingService = new BookServiceImpl(new FailingBookDao());
ServiceResult<List<Book>> unavailable = failingService.searchBooks(new BookSearchCriteria());
require(!unavailable.isSuccessful(), "DAO failure should not escape service");
require(UNAVAILABLE_MESSAGE.equals(unavailable.getMessage()), "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.VIEW_CATALOG));
}
private static Book book(long id, String identifier, String title, String author, long categoryId,
int totalCopies, int availableCopies, BookStatus status) {
Book book = new Book();
book.setId(id);
book.setIdentifier(identifier);
book.setTitle(title);
book.setAuthor(author);
book.setCategoryId(categoryId);
book.setTotalCopies(totalCopies);
book.setAvailableCopies(availableCopies);
book.setStatus(status);
return book;
}
private static void require(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
private static final class InMemoryBookDao implements BookDao {
private final Map<Long, Book> books = new LinkedHashMap<>();
private long nextId = 1L;
@Override
public List<BookCategory> findAllCategories() {
BookCategory category = new BookCategory();
category.setId(1L);
category.setName("Computer Science");
return Collections.singletonList(category);
}
@Override
public List<Book> search(BookSearchCriteria criteria) {
List<Book> matches = new ArrayList<>();
for (Book book : books.values()) {
if (matches(criteria.getIdentifier(), book.getIdentifier())
&& matches(criteria.getTitle(), book.getTitle())
&& matches(criteria.getAuthor(), book.getAuthor())
&& (criteria.getCategoryId() == null || criteria.getCategoryId() == book.getCategoryId())) {
matches.add(copy(book));
}
}
return matches;
}
@Override
public Optional<Book> findById(long id) {
return Optional.ofNullable(books.get(id)).map(this::copy);
}
@Override
public Optional<Book> findByIdentifier(String identifier) {
for (Book book : books.values()) {
if (book.getIdentifier().equals(identifier)) {
return Optional.of(copy(book));
}
}
return Optional.empty();
}
@Override
public long create(Book book) {
long id = nextId++;
Book stored = copy(book);
stored.setId(id);
books.put(id, stored);
return id;
}
@Override
public boolean update(Book book) {
if (!books.containsKey(book.getId())) {
return false;
}
books.put(book.getId(), copy(book));
return true;
}
@Override
public boolean delete(long id) {
return books.remove(id) != null;
}
private boolean matches(String filter, String value) {
return filter == null || filter.isEmpty() || value.contains(filter);
}
private Book copy(Book source) {
Book copy = book(source.getId(), source.getIdentifier(), source.getTitle(), source.getAuthor(),
source.getCategoryId(), source.getTotalCopies(), source.getAvailableCopies(), source.getStatus());
copy.setCategoryName(source.getCategoryName());
return copy;
}
}
private static final class FailingBookDao implements BookDao {
@Override
public List<BookCategory> findAllCategories() {
throw new DaoException("Simulated category failure", null);
}
@Override
public List<Book> search(BookSearchCriteria criteria) {
throw new DaoException("Simulated search failure", null);
}
@Override
public Optional<Book> findById(long id) {
throw new DaoException("Simulated find failure", null);
}
@Override
public Optional<Book> findByIdentifier(String identifier) {
throw new DaoException("Simulated find failure", null);
}
@Override
public long create(Book book) {
throw new DaoException("Simulated create failure", null);
}
@Override
public boolean update(Book book) {
throw new DaoException("Simulated update failure", null);
}
@Override
public boolean delete(long id) {
throw new DaoException("Simulated delete failure", null);
}
}
}