diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md index 9ebdee1..7ac6738 100644 --- a/.trellis/spec/backend/database-guidelines.md +++ b/.trellis/spec/backend/database-guidelines.md @@ -190,6 +190,95 @@ books/form.jsp -> JDBC -> INSERT INTO books using request parameters books/form.jsp -> BookManagementServlet -> BookService -> BookDao -> books ``` +## Scenario: Book Category Maintenance Slice + +### 1. Scope / Trigger + +- Trigger: category maintenance completes the book-management core requirement + by adding staff-managed CRUD for `book_categories`, while existing book forms + and catalog searches continue to consume the same category source. +- Schema path: `src/main/resources/db/schema.sql`. +- JSP paths: `WEB-INF/jsp/books/categories.jsp` and + `WEB-INF/jsp/books/category-form.jsp`. + +### 2. Signatures + +- DAO signatures: `BookDao.findAllCategories()`, `findCategoryById(long id)`, + `findCategoryByName(String name)`, `createCategory(BookCategory category)`, + `updateCategory(BookCategory category)`, `deleteCategory(long id)`, and + `countBooksByCategoryId(long categoryId)`. +- Entity signature: `BookCategory(id, name, description)`. +- Service signatures: `BookService.listCategories()`, + `findCategory(long id)`, `createCategory(AuthenticatedUser actor, + BookCategory category)`, `updateCategory(AuthenticatedUser actor, + BookCategory category)`, and `deleteCategory(AuthenticatedUser actor, + long id)`, all returning `ServiceResult`. +- Routes: `GET /book-categories`, `GET /book-categories/new`, + `GET /book-categories/edit?id=...`, `POST /book-categories`, + `POST /book-categories/update`, and `POST /book-categories/delete`. +- Protected permission: `/book-categories*` requires `MANAGE_BOOKS`. + +### 3. Contracts + +- `book_categories.name` is unique and is the display value used in book forms, + catalog filters, and management filters. +- `book_categories.description` is optional and limited to the database column + size. +- Book category deletes must check `books.category_id` usage before deletion + and return a safe validation result when the category is in use. +- Servlet controllers set JSP attributes such as `categories`, `category`, + `formTitle`, `formAction`, `formValues`, `errors`, `errorMessage`, and + `successMessage`. +- JSP pages render JavaBean properties only; they must not call DAOs or embed + SQL. + +### 4. Validation & Error Matrix + +- Missing category name -> field error on `name`. +- Category name longer than 96 characters -> field error on `name`. +- Description longer than 255 characters -> field error on `description`. +- Duplicate category name -> field error on `name`. +- Missing or non-positive category id for edit/delete -> `Select a valid + category.` +- Delete category used by any `books` row -> `Category is used by existing + books and cannot be deleted.` +- Reader or unauthenticated actor attempts mutation -> permission denial through + filter/service. +- DAO failure during list/search/write -> log server-side details and return + `Book service is temporarily unavailable. Please try again later.` + +### 5. Good/Base/Bad Cases + +- Good: a librarian creates `Architecture`, selects it on a book form, and sees + it in catalog filters. +- Base: `/book-categories` lists seed categories ordered by name. +- Bad: deleting a category with existing books surfaces a MySQL foreign-key + stack trace or lets JSP code perform the delete. + +### 6. Tests Required + +- Run `BookServiceCheck` assertions for reader category-write denial, duplicate + category names, successful create/update/delete, and used-category delete + rejection. +- Run `PermissionPolicyCheck` to confirm `MANAGE_BOOKS` remains staff-only. +- Scan category 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 +categories.jsp -> JDBC -> DELETE FROM book_categories WHERE id = request.id +``` + +#### Correct + +```text +categories.jsp -> BookManagementServlet -> BookService -> BookDao -> book_categories +``` + ## Scenario: Reader Information Management Slice ### 1. Scope / Trigger diff --git a/.trellis/tasks/04-27-core-function-gap-check/check.jsonl b/.trellis/tasks/04-27-core-function-gap-check/check.jsonl new file mode 100644 index 0000000..5f7ed21 --- /dev/null +++ b/.trellis/tasks/04-27-core-function-gap-check/check.jsonl @@ -0,0 +1,7 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Verify category maintenance against backend core module expectations."} +{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Verify category DAO/service contracts and book-category integrity behavior."} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Verify validation and safe fallback messages."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Verify layer boundaries and test expectations."} +{"file": ".trellis/spec/frontend/index.md", "reason": "Verify JSP/CSS work stays in the approved frontend stack."} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Verify page composition uses existing forms/tables/navigation patterns."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Verify JSP safety, empty states, errors, and permission-specific navigation."} diff --git a/.trellis/tasks/04-27-core-function-gap-check/implement.jsonl b/.trellis/tasks/04-27-core-function-gap-check/implement.jsonl new file mode 100644 index 0000000..07be67e --- /dev/null +++ b/.trellis/tasks/04-27-core-function-gap-check/implement.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Category maintenance must follow backend layer and core module expectations."} +{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Defines book/category data contracts, DAO responsibilities, validation, and DB integrity rules."} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Guides safe service errors, field validation, and controller behavior."} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Category maintenance is a key book operation and should preserve logging expectations."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Implementation must preserve Servlet-Service-DAO separation and validation checks."} +{"file": ".trellis/spec/frontend/index.md", "reason": "JSP/CSS changes must remain within the server-rendered frontend conventions."} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Category pages should reuse existing form, table, empty-state, and navigation patterns."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Check JSP safety, forms, tables, permissions, and accessibility basics."} diff --git a/.trellis/tasks/04-27-core-function-gap-check/prd.md b/.trellis/tasks/04-27-core-function-gap-check/prd.md new file mode 100644 index 0000000..04d7d12 --- /dev/null +++ b/.trellis/tasks/04-27-core-function-gap-check/prd.md @@ -0,0 +1,93 @@ +# Core Function Gap Check + +## Goal + +Check the current MZH Library Management implementation against the documented +core modules and complete the highest-confidence missing core feature slice +without broad redesign. + +## What I Already Know + +* The user asked to check whether core functionality is still missing and to + complete it. +* The app is a Java 11 Maven WAR using JSP + Servlet + MySQL and JDBC DAOs. +* Existing implemented slices cover login/permissions, dashboard navigation, + book catalog/search, book CRUD, reader management, borrowing circulation, + reader loan history, reports, administrator user management, and system-log + viewing. +* Existing lightweight checks pass with `javac -Xlint:all` for non-Servlet + layers and all service check mains. Maven is unavailable in this environment. +* The clearest missing core requirement is book category maintenance. The + schema and selectors already have `book_categories`, but there is no route, + controller, JSP, DAO/service mutation API, or test coverage for maintaining + categories. + +## Requirements + +* Preserve the existing JSP -> Servlet -> Service -> DAO -> MySQL layering. +* Keep category maintenance under the existing `MANAGE_BOOKS` permission. +* Add a staff-only category management flow for listing, creating, editing, and + deleting book categories. +* Validate required category name, name length, description length, duplicate + names, and invalid IDs with field-level service errors. +* Prevent deleting categories that still have book records, returning a safe + validation message instead of surfacing a database constraint failure. +* Reuse the existing book management visual patterns, flash messages, and + table/form conventions. +* Link category maintenance from the book management surface and staff + navigation where appropriate. +* Update focused service checks and fallback validation commands. + +## Acceptance Criteria + +* [x] A user with `MANAGE_BOOKS` can open a category management page. +* [x] Staff can create and update category names/descriptions. +* [x] Duplicate category names are rejected with a field error. +* [x] Categories used by books cannot be deleted. +* [x] Readers or unauthenticated users cannot mutate categories. +* [x] Book forms/search continue to load categories from the shared DAO/service + path. +* [x] JSPs do not contain SQL/JDBC/scriptlet logic. +* [x] Existing lightweight checks pass; Maven limitation is documented if still + unavailable. + +## Definition of Done + +* Tests/checks updated where practical. +* Lint/type-check/compile equivalent checks pass in this environment. +* Docs/notes updated if behavior changes. +* No broad framework or visual redesign. + +## Out of Scope + +* Role/permission editor UI. +* Full database dump/restore execution from the web app. +* Audit logging expansion for every non-user operation. +* Automatic reader-account/profile linking changes. + +## Technical Notes + +* Relevant specs: + `.trellis/spec/backend/index.md`, + `.trellis/spec/backend/database-guidelines.md`, + `.trellis/spec/backend/error-handling.md`, + `.trellis/spec/backend/logging-guidelines.md`, + `.trellis/spec/backend/quality-guidelines.md`, + `.trellis/spec/frontend/index.md`, + `.trellis/spec/frontend/component-guidelines.md`, + `.trellis/spec/frontend/quality-guidelines.md`. +* Current files most likely affected: + `BookDao`, `JdbcBookDao`, `BookService`, `BookServiceImpl`, + `BookManagementServlet`, `web.xml`, book JSPs, shared CSS, and + `BookServiceCheck`. +* Initial verification before implementation: + `javac -Xlint:all` over non-Servlet app layers and tests passed; all eight + service check mains passed. `mvn` is not installed. +* Final verification after implementation: + `javac -Xlint:all` over non-Servlet app layers and tests passed; + `PermissionPolicyCheck`, `AuthServiceCheck`, `BookServiceCheck`, + `ReaderServiceCheck`, `BorrowingServiceCheck`, `ReportServiceCheck`, + `UserAccountServiceCheck`, and `SystemLogServiceCheck` passed; + JSP/static scriptlet and SQL/JDBC scan returned no matches; + `git diff --check` passed; `mvn clean package` remains blocked because `mvn` + is not installed. diff --git a/.trellis/tasks/04-27-core-function-gap-check/task.json b/.trellis/tasks/04-27-core-function-gap-check/task.json new file mode 100644 index 0000000..473a90f --- /dev/null +++ b/.trellis/tasks/04-27-core-function-gap-check/task.json @@ -0,0 +1,26 @@ +{ + "id": "core-function-gap-check", + "name": "core-function-gap-check", + "title": "检查并补全核心功能", + "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 c6b966e..57a91ad 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,9 @@ mvn clean package 5. Deploy `target/library-management.war` to Tomcat. -The implemented scaffold slices now cover login/permission checks, catalog and book management, reader profile management, borrowing circulation, reader loan history, and the staff report center. Authentication stores only a safe authenticated-user snapshot in the HTTP session, and business workflows stay in Servlet -> Service -> DAO boundaries. +The implemented scaffold slices now cover login/permission checks, catalog and +book management, book category maintenance, reader profile management, +borrowing circulation, reader loan history, the staff report center, +administrator user management, and system-log viewing. 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/BookManagementServlet.java b/src/main/java/com/mzh/library/controller/BookManagementServlet.java index f5a6da0..124a0fb 100644 --- a/src/main/java/com/mzh/library/controller/BookManagementServlet.java +++ b/src/main/java/com/mzh/library/controller/BookManagementServlet.java @@ -27,6 +27,8 @@ 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 CATEGORY_MANAGE_JSP = "/WEB-INF/jsp/books/categories.jsp"; + private static final String CATEGORY_FORM_JSP = "/WEB-INF/jsp/books/category-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"; @@ -50,6 +52,19 @@ public class BookManagementServlet extends HttpServlet { showEditForm(request, response); return; } + if ("/book-categories".equals(path)) { + showCategoryList(request, response); + return; + } + if ("/book-categories/new".equals(path)) { + renderCategoryForm(request, response, "Create category", "/book-categories", new BookCategory(), + Collections.emptyMap(), Collections.emptyMap(), null); + return; + } + if ("/book-categories/edit".equals(path)) { + showEditCategoryForm(request, response); + return; + } if (!"/books".equals(path)) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return; @@ -73,6 +88,18 @@ public class BookManagementServlet extends HttpServlet { deleteBook(request, response); return; } + if ("/book-categories".equals(path)) { + createCategory(request, response); + return; + } + if ("/book-categories/update".equals(path)) { + updateCategory(request, response); + return; + } + if ("/book-categories/delete".equals(path)) { + deleteCategory(request, response); + return; + } response.sendError(HttpServletResponse.SC_NOT_FOUND); } @@ -116,6 +143,32 @@ public class BookManagementServlet extends HttpServlet { Collections.emptyMap(), Collections.emptyMap(), null); } + private void showCategoryList(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + applyFlash(request); + + ServiceResult> result = bookService.listCategories(); + request.setAttribute("categories", result.isSuccessful() ? result.getData() : Collections.emptyList()); + if (!result.isSuccessful()) { + request.setAttribute("errorMessage", result.getMessage()); + } + request.getRequestDispatcher(CATEGORY_MANAGE_JSP).forward(request, response); + } + + private void showEditCategoryForm(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + long id = requiredLong(request.getParameter("id"), -1L); + ServiceResult> result = bookService.findCategory(id); + if (!result.isSuccessful() || !result.getData().isPresent()) { + flashError(request, result.isSuccessful() ? "Category was not found." : result.getMessage()); + response.sendRedirect(request.getContextPath() + "/book-categories"); + return; + } + + renderCategoryForm(request, response, "Edit category", "/book-categories/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()) { @@ -167,6 +220,60 @@ public class BookManagementServlet extends HttpServlet { response.sendRedirect(request.getContextPath() + "/books"); } + private void createCategory(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + CategoryForm form = readCategoryForm(request, false); + if (!form.getErrors().isEmpty()) { + renderCategoryForm(request, response, "Create category", "/book-categories", form.getCategory(), + form.getValues(), form.getErrors(), "Please correct the highlighted category fields."); + return; + } + + ServiceResult result = bookService.createCategory(currentUser(request), form.getCategory()); + if (!result.isSuccessful()) { + handleCategoryFormFailure(request, response, "Create category", "/book-categories", form, result); + return; + } + + flashSuccess(request, result.getMessage()); + response.sendRedirect(request.getContextPath() + "/book-categories"); + } + + private void updateCategory(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + CategoryForm form = readCategoryForm(request, true); + if (!form.getErrors().isEmpty()) { + renderCategoryForm(request, response, "Edit category", "/book-categories/update", form.getCategory(), + form.getValues(), form.getErrors(), "Please correct the highlighted category fields."); + return; + } + + ServiceResult result = bookService.updateCategory(currentUser(request), form.getCategory()); + if (!result.isSuccessful()) { + handleCategoryFormFailure(request, response, "Edit category", "/book-categories/update", form, result); + return; + } + + flashSuccess(request, result.getMessage()); + response.sendRedirect(request.getContextPath() + "/book-categories"); + } + + private void deleteCategory(HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + long id = requiredLong(request.getParameter("id"), -1L); + ServiceResult result = bookService.deleteCategory(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() + "/book-categories"); + } + private void handleFormFailure(HttpServletRequest request, HttpServletResponse response, String title, String action, BookForm form, ServiceResult result) throws ServletException, IOException { @@ -178,6 +285,17 @@ public class BookManagementServlet extends HttpServlet { result.getMessage()); } + private void handleCategoryFormFailure(HttpServletRequest request, HttpServletResponse response, String title, + String action, CategoryForm form, ServiceResult result) + throws ServletException, IOException { + if (isPermissionDenied(result)) { + forwardDenied(request, response, result.getMessage()); + return; + } + renderCategoryForm(request, response, title, action, form.getCategory(), form.getValues(), + result.getErrors(), result.getMessage()); + } + private void renderForm(HttpServletRequest request, HttpServletResponse response, String title, String action, Book book, Map formValues, Map errors, String errorMessage) throws ServletException, IOException { @@ -199,6 +317,21 @@ public class BookManagementServlet extends HttpServlet { request.getRequestDispatcher(FORM_JSP).forward(request, response); } + private void renderCategoryForm(HttpServletRequest request, HttpServletResponse response, String title, + String action, BookCategory category, Map formValues, + Map errors, String errorMessage) + throws ServletException, IOException { + request.setAttribute("formTitle", title); + request.setAttribute("formAction", action); + request.setAttribute("category", category); + request.setAttribute("formValues", formValues); + request.setAttribute("errors", errors); + if (errorMessage != null && !errorMessage.isEmpty()) { + request.setAttribute("errorMessage", errorMessage); + } + request.getRequestDispatcher(CATEGORY_FORM_JSP).forward(request, response); + } + private BookForm readBookForm(HttpServletRequest request, boolean requireId) { Map values = formValues(request); Map errors = new LinkedHashMap<>(); @@ -237,6 +370,27 @@ public class BookManagementServlet extends HttpServlet { return values; } + private CategoryForm readCategoryForm(HttpServletRequest request, boolean requireId) { + Map values = categoryFormValues(request); + Map errors = new LinkedHashMap<>(); + BookCategory category = new BookCategory(); + + if (requireId) { + category.setId(parseLong(values.get("id"), "id", "Select a valid category.", errors)); + } + category.setName(values.get("name")); + category.setDescription(values.get("description")); + return new CategoryForm(category, values, errors); + } + + private Map categoryFormValues(HttpServletRequest request) { + Map values = new LinkedHashMap<>(); + values.put("id", trim(request.getParameter("id"))); + values.put("name", trim(request.getParameter("name"))); + values.put("description", trim(request.getParameter("description"))); + return values; + } + private BookSearchCriteria searchCriteria(HttpServletRequest request) { return new BookSearchCriteria( request.getParameter("identifier"), @@ -368,4 +522,28 @@ public class BookManagementServlet extends HttpServlet { return errors; } } + + private static final class CategoryForm { + private final BookCategory category; + private final Map values; + private final Map errors; + + private CategoryForm(BookCategory category, Map values, Map errors) { + this.category = category; + this.values = values; + this.errors = errors; + } + + private BookCategory getCategory() { + return category; + } + + private Map getValues() { + return values; + } + + private Map getErrors() { + return errors; + } + } } diff --git a/src/main/java/com/mzh/library/dao/BookDao.java b/src/main/java/com/mzh/library/dao/BookDao.java index b845d90..ade4df6 100644 --- a/src/main/java/com/mzh/library/dao/BookDao.java +++ b/src/main/java/com/mzh/library/dao/BookDao.java @@ -10,6 +10,18 @@ import java.util.Optional; public interface BookDao { List findAllCategories(); + Optional findCategoryById(long id); + + Optional findCategoryByName(String name); + + long createCategory(BookCategory category); + + boolean updateCategory(BookCategory category); + + boolean deleteCategory(long id); + + int countBooksByCategoryId(long categoryId); + List search(BookSearchCriteria criteria); Optional findById(long id); diff --git a/src/main/java/com/mzh/library/dao/impl/JdbcBookDao.java b/src/main/java/com/mzh/library/dao/impl/JdbcBookDao.java index 9d68b14..a1681d5 100644 --- a/src/main/java/com/mzh/library/dao/impl/JdbcBookDao.java +++ b/src/main/java/com/mzh/library/dao/impl/JdbcBookDao.java @@ -33,6 +33,32 @@ public class JdbcBookDao implements BookDao { + "FROM book_categories " + "ORDER BY name"; + private static final String FIND_CATEGORY_BY_ID = "" + + "SELECT id, name, description " + + "FROM book_categories " + + "WHERE id = ?"; + + private static final String FIND_CATEGORY_BY_NAME = "" + + "SELECT id, name, description " + + "FROM book_categories " + + "WHERE name = ?"; + + private static final String CREATE_CATEGORY = "" + + "INSERT INTO book_categories (name, description) " + + "VALUES (?, ?)"; + + private static final String UPDATE_CATEGORY = "" + + "UPDATE book_categories " + + "SET name = ?, description = ? " + + "WHERE id = ?"; + + private static final String DELETE_CATEGORY = "DELETE FROM book_categories WHERE id = ?"; + + private static final String COUNT_BOOKS_BY_CATEGORY = "" + + "SELECT COUNT(*) " + + "FROM books " + + "WHERE category_id = ?"; + 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 @@ -66,6 +92,86 @@ public class JdbcBookDao implements BookDao { } } + @Override + public Optional findCategoryById(long id) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(FIND_CATEGORY_BY_ID)) { + statement.setLong(1, id); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? Optional.of(mapCategory(resultSet)) : Optional.empty(); + } + } catch (SQLException ex) { + throw new DaoException("Unable to load book category by id", ex); + } + } + + @Override + public Optional findCategoryByName(String name) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(FIND_CATEGORY_BY_NAME)) { + statement.setString(1, name); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? Optional.of(mapCategory(resultSet)) : Optional.empty(); + } + } catch (SQLException ex) { + throw new DaoException("Unable to load book category by name", ex); + } + } + + @Override + public long createCategory(BookCategory category) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(CREATE_CATEGORY, Statement.RETURN_GENERATED_KEYS)) { + bindCategory(statement, category); + statement.executeUpdate(); + + try (ResultSet generatedKeys = statement.getGeneratedKeys()) { + if (generatedKeys.next()) { + return generatedKeys.getLong(1); + } + } + throw new DaoException("Unable to read generated book category id", null); + } catch (SQLException ex) { + throw new DaoException("Unable to create book category", ex); + } + } + + @Override + public boolean updateCategory(BookCategory category) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(UPDATE_CATEGORY)) { + bindCategory(statement, category); + statement.setLong(3, category.getId()); + return statement.executeUpdate() == 1; + } catch (SQLException ex) { + throw new DaoException("Unable to update book category", ex); + } + } + + @Override + public boolean deleteCategory(long id) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(DELETE_CATEGORY)) { + statement.setLong(1, id); + return statement.executeUpdate() == 1; + } catch (SQLException ex) { + throw new DaoException("Unable to delete book category", ex); + } + } + + @Override + public int countBooksByCategoryId(long categoryId) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(COUNT_BOOKS_BY_CATEGORY)) { + statement.setLong(1, categoryId); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? resultSet.getInt(1) : 0; + } + } catch (SQLException ex) { + throw new DaoException("Unable to count books by category", ex); + } + } + @Override public List search(BookSearchCriteria criteria) { List parameters = new ArrayList<>(); @@ -194,6 +300,11 @@ public class JdbcBookDao implements BookDao { statement.setString(7, book.getStatus().getCode()); } + private void bindCategory(PreparedStatement statement, BookCategory category) throws SQLException { + statement.setString(1, category.getName()); + statement.setString(2, category.getDescription()); + } + private Book mapBook(ResultSet resultSet) throws SQLException { Book book = new Book(); book.setId(resultSet.getLong("id")); diff --git a/src/main/java/com/mzh/library/filter/AuthorizationFilter.java b/src/main/java/com/mzh/library/filter/AuthorizationFilter.java index 4c2418b..5967530 100644 --- a/src/main/java/com/mzh/library/filter/AuthorizationFilter.java +++ b/src/main/java/com/mzh/library/filter/AuthorizationFilter.java @@ -29,6 +29,7 @@ public class AuthorizationFilter implements Filter { new PathRule("/admin/system-logs", Permission.VIEW_SYSTEM_LOGS), new PathRule("/reports", Permission.VIEW_REPORTS), new PathRule("/borrowing", Permission.MANAGE_BORROWING), + new PathRule("/book-categories", Permission.MANAGE_BOOKS), new PathRule("/books", Permission.MANAGE_BOOKS), new PathRule("/readers", Permission.MANAGE_READERS), new PathRule("/catalog", Permission.VIEW_CATALOG), diff --git a/src/main/java/com/mzh/library/service/BookService.java b/src/main/java/com/mzh/library/service/BookService.java index dae33a0..23b25d0 100644 --- a/src/main/java/com/mzh/library/service/BookService.java +++ b/src/main/java/com/mzh/library/service/BookService.java @@ -11,6 +11,14 @@ import java.util.Optional; public interface BookService { ServiceResult> listCategories(); + ServiceResult> findCategory(long id); + + ServiceResult createCategory(AuthenticatedUser actor, BookCategory category); + + ServiceResult updateCategory(AuthenticatedUser actor, BookCategory category); + + ServiceResult deleteCategory(AuthenticatedUser actor, long id); + ServiceResult> searchBooks(BookSearchCriteria criteria); ServiceResult> findBook(long id); diff --git a/src/main/java/com/mzh/library/service/impl/BookServiceImpl.java b/src/main/java/com/mzh/library/service/impl/BookServiceImpl.java index 21ad578..3ca6da4 100644 --- a/src/main/java/com/mzh/library/service/impl/BookServiceImpl.java +++ b/src/main/java/com/mzh/library/service/impl/BookServiceImpl.java @@ -23,6 +23,7 @@ public class BookServiceImpl implements BookService { 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 CATEGORY_VALIDATION_MESSAGE = "Please correct the highlighted category fields."; private static final String DENIED_MESSAGE = "You do not have permission to manage books."; private final BookDao bookDao; @@ -47,6 +48,111 @@ public class BookServiceImpl implements BookService { } } + @Override + public ServiceResult> findCategory(long id) { + if (id <= 0) { + return ServiceResult.failure("Select a valid category."); + } + + try { + return ServiceResult.success(bookDao.findCategoryById(id)); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to load book category id=" + id, ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult createCategory(AuthenticatedUser actor, BookCategory category) { + if (!canManageBooks(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + normalize(category); + Map errors = validate(category, false); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(CATEGORY_VALIDATION_MESSAGE, errors); + } + + try { + if (bookDao.findCategoryByName(category.getName()).isPresent()) { + errors.put("name", "Category name is already in use."); + return ServiceResult.validationFailure(CATEGORY_VALIDATION_MESSAGE, errors); + } + + long id = bookDao.createCategory(category); + LOGGER.info("Created book category id=" + id + " actorId=" + actor.getId()); + return ServiceResult.success(id, "Category created."); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to create book category actorId=" + actor.getId() + + " name=" + safeCategoryName(category), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult updateCategory(AuthenticatedUser actor, BookCategory category) { + if (!canManageBooks(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + normalize(category); + Map errors = validate(category, true); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(CATEGORY_VALIDATION_MESSAGE, errors); + } + + try { + Optional existingWithName = bookDao.findCategoryByName(category.getName()); + if (existingWithName.isPresent() && existingWithName.get().getId() != category.getId()) { + errors.put("name", "Category name is already in use."); + return ServiceResult.validationFailure(CATEGORY_VALIDATION_MESSAGE, errors); + } + + if (!bookDao.updateCategory(category)) { + return ServiceResult.failure("Category was not found."); + } + + LOGGER.info("Updated book category id=" + category.getId() + " actorId=" + actor.getId()); + return ServiceResult.success(null, "Category updated."); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to update book category id=" + category.getId() + + " actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult deleteCategory(AuthenticatedUser actor, long id) { + if (!canManageBooks(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + if (id <= 0) { + return ServiceResult.failure("Select a valid category."); + } + + try { + if (!bookDao.findCategoryById(id).isPresent()) { + return ServiceResult.failure("Category was not found."); + } + if (bookDao.countBooksByCategoryId(id) > 0) { + Map errors = new LinkedHashMap<>(); + errors.put("category", "Category is used by existing books and cannot be deleted."); + return ServiceResult.validationFailure("Category is used by existing books and cannot be deleted.", + errors); + } + if (!bookDao.deleteCategory(id)) { + return ServiceResult.failure("Category was not found."); + } + + LOGGER.info("Deleted book category id=" + id + " actorId=" + actor.getId()); + return ServiceResult.success(null, "Category deleted."); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to delete book category id=" + id + " actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + @Override public ServiceResult> searchBooks(BookSearchCriteria criteria) { BookSearchCriteria normalized = criteria == null ? new BookSearchCriteria() : criteria; @@ -171,6 +277,14 @@ public class BookServiceImpl implements BookService { book.setAuthor(trim(book.getAuthor())); } + private void normalize(BookCategory category) { + if (category == null) { + return; + } + category.setName(trim(category.getName())); + category.setDescription(trim(category.getDescription())); + } + private Map validate(Book book, boolean requireId) { Map errors = new LinkedHashMap<>(); if (book == null) { @@ -202,6 +316,27 @@ public class BookServiceImpl implements BookService { return errors; } + private Map validate(BookCategory category, boolean requireId) { + Map errors = new LinkedHashMap<>(); + if (category == null) { + errors.put("category", "Category details are required."); + return errors; + } + + if (requireId && category.getId() <= 0) { + errors.put("id", "Select a valid category."); + } + requireLength(errors, "name", category.getName(), "Category name", 96); + if (category.getDescription() != null && category.getDescription().length() > 255) { + errors.put("description", "Description must be 255 characters or fewer."); + } + return errors; + } + + private String safeCategoryName(BookCategory category) { + return category == null ? "" : category.getName(); + } + private void requireLength(Map errors, String field, String value, String label, int maxLength) { if (value == null || value.isEmpty()) { errors.put(field, label + " is required."); diff --git a/src/main/webapp/WEB-INF/jsp/books/categories.jsp b/src/main/webapp/WEB-INF/jsp/books/categories.jsp new file mode 100644 index 0000000..65dedc0 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/books/categories.jsp @@ -0,0 +1,89 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> + + + + + + Manage Categories - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+
+

Book Management

+

Manage categories

+

Maintain catalog groupings used by book records and search filters.

+
+ +
+ + +
+ +
+
+ + + + +
+

Category records

+ + +

No categories have been created yet.

+
+ +
+ + + + + + + + + + + + + + + + + +
NameDescriptionActions
+ + + No description + + + + + + +
+ Edit +
+ + +
+
+
+
+
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/books/category-form.jsp b/src/main/webapp/WEB-INF/jsp/books/category-form.jsp new file mode 100644 index 0000000..b418be4 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/books/category-form.jsp @@ -0,0 +1,66 @@ +<%@ 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" %> +
+
+

Book Management

+

+ + + + + + + + + +
+ + + + +
+
+ + + + + +
+ +
+ + + + + +
+
+ + + + + +
+ + Cancel +
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/books/manage.jsp b/src/main/webapp/WEB-INF/jsp/books/manage.jsp index 37472ad..cc6b144 100644 --- a/src/main/webapp/WEB-INF/jsp/books/manage.jsp +++ b/src/main/webapp/WEB-INF/jsp/books/manage.jsp @@ -16,7 +16,10 @@

Book Management

Manage books

Create, update, delete, and review inventory for catalog records.

- New book + @@ -65,6 +68,7 @@ Clear View catalog + Categories diff --git a/src/main/webapp/WEB-INF/jsp/common/header.jspf b/src/main/webapp/WEB-INF/jsp/common/header.jspf index 15c6bf5..71ac742 100644 --- a/src/main/webapp/WEB-INF/jsp/common/header.jspf +++ b/src/main/webapp/WEB-INF/jsp/common/header.jspf @@ -13,6 +13,7 @@ Librarian Books + Categories Readers Borrowing Reports diff --git a/src/main/webapp/WEB-INF/jsp/dashboard.jsp b/src/main/webapp/WEB-INF/jsp/dashboard.jsp index 4e14118..80d716b 100644 --- a/src/main/webapp/WEB-INF/jsp/dashboard.jsp +++ b/src/main/webapp/WEB-INF/jsp/dashboard.jsp @@ -53,6 +53,12 @@ Open +
+

Category Maintenance

+

Maintain catalog categories used by book records and search filters.

+ Open +
+

Reader Management

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

diff --git a/src/main/webapp/WEB-INF/jsp/role-home.jsp b/src/main/webapp/WEB-INF/jsp/role-home.jsp index 6192eba..f9b8ab4 100644 --- a/src/main/webapp/WEB-INF/jsp/role-home.jsp +++ b/src/main/webapp/WEB-INF/jsp/role-home.jsp @@ -48,6 +48,12 @@ Manage books
+
+

Category Maintenance

+

Create, update, and retire catalog categories used by book records.

+ Manage categories +
+

Reader Management

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

diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 8117e00..87efc5f 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -117,6 +117,11 @@ /books/edit /books/update /books/delete + /book-categories + /book-categories/new + /book-categories/edit + /book-categories/update + /book-categories/delete diff --git a/src/main/webapp/static/css/app.css b/src/main/webapp/static/css/app.css index f2ec2aa..ca8e3d1 100644 --- a/src/main/webapp/static/css/app.css +++ b/src/main/webapp/static/css/app.css @@ -313,6 +313,12 @@ h2 { margin-top: 0; } +.hero-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + .toolbar-panel, .table-panel, .form-panel { @@ -350,6 +356,8 @@ h2 { .search-form select, .book-form input, .book-form select, +.category-form input, +.category-form textarea, .reader-form input, .reader-form select, .user-form input, @@ -368,6 +376,8 @@ h2 { .search-form select:focus, .book-form input:focus, .book-form select:focus, +.category-form input:focus, +.category-form textarea:focus, .reader-form input:focus, .reader-form select:focus, .user-form input:focus, @@ -394,7 +404,8 @@ h2 { } .user-table, -.system-log-table { +.system-log-table, +.category-table { min-width: 980px; } @@ -504,6 +515,7 @@ h2 { } .book-form, +.category-form, .reader-form, .user-form, .borrow-form { @@ -522,6 +534,15 @@ h2 { gap: 6px; } +.form-field-wide { + grid-column: 1 / -1; +} + +.category-form textarea { + min-height: 112px; + resize: vertical; +} + .form-field label { color: var(--color-muted); font-size: 14px; diff --git a/src/test/java/com/mzh/library/service/BookServiceCheck.java b/src/test/java/com/mzh/library/service/BookServiceCheck.java index 210c295..eb44ff0 100644 --- a/src/test/java/com/mzh/library/service/BookServiceCheck.java +++ b/src/test/java/com/mzh/library/service/BookServiceCheck.java @@ -12,7 +12,6 @@ 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; @@ -48,6 +47,12 @@ public final class BookServiceCheck { require("You do not have permission to manage books.".equals(denied.getMessage()), "reader write should return permission message"); + ServiceResult deniedCategory = service.createCategory(reader, + category(0L, "Reader Category", "Denied category")); + require(!deniedCategory.isSuccessful(), "reader category create should fail"); + require("You do not have permission to manage books.".equals(deniedCategory.getMessage()), + "reader category write should return permission message"); + ServiceResult 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"); @@ -63,6 +68,13 @@ public final class BookServiceCheck { require(updated.isSuccessful(), "librarian should update a valid book"); require(dao.findById(createdId).get().getAvailableCopies() == 3, "update should persist available copies"); + ServiceResult deleteUsedCategory = service.deleteCategory(librarian, 1L); + require(!deleteUsedCategory.isSuccessful(), "used category delete should fail"); + require("Category is used by existing books and cannot be deleted.".equals(deleteUsedCategory.getMessage()), + "used category delete should return a safe specific message"); + require(deleteUsedCategory.getErrors().containsKey("category"), + "used category delete should return a category-level field error"); + ServiceResult> search = service.searchBooks(new BookSearchCriteria("BK-1003", "", "", null)); require(search.isSuccessful(), "search should succeed"); require(search.getData().size() == 1, "search should find updated identifier"); @@ -71,6 +83,26 @@ public final class BookServiceCheck { require(deleted.isSuccessful(), "librarian should delete a book"); require(!dao.findById(createdId).isPresent(), "delete should remove the record"); + ServiceResult createdCategory = service.createCategory(librarian, + category(0L, "Architecture", "Design and systems")); + require(createdCategory.isSuccessful(), "librarian should create a category"); + long categoryId = createdCategory.getData(); + + ServiceResult duplicateCategory = service.createCategory(librarian, + category(0L, "Architecture", "Duplicate category")); + require(!duplicateCategory.isSuccessful(), "duplicate category should fail"); + require(duplicateCategory.getErrors().containsKey("name"), "duplicate category should target name field"); + + ServiceResult updatedCategory = service.updateCategory(librarian, + category(categoryId, "Software Architecture", "Updated category")); + require(updatedCategory.isSuccessful(), "librarian should update a category"); + require("Software Architecture".equals(dao.findCategoryById(categoryId).get().getName()), + "category update should persist the new name"); + + ServiceResult deletedCategory = service.deleteCategory(librarian, categoryId); + require(deletedCategory.isSuccessful(), "unused category should be deleted"); + require(!dao.findCategoryById(categoryId).isPresent(), "category delete should remove unused category"); + BookService failingService = new BookServiceImpl(new FailingBookDao()); ServiceResult> unavailable = failingService.searchBooks(new BookSearchCriteria()); require(!unavailable.isSuccessful(), "DAO failure should not escape service"); @@ -98,6 +130,14 @@ public final class BookServiceCheck { return book; } + private static BookCategory category(long id, String name, String description) { + BookCategory category = new BookCategory(); + category.setId(id); + category.setName(name); + category.setDescription(description); + return category; + } + private static void require(boolean condition, String message) { if (!condition) { throw new AssertionError(message); @@ -106,14 +146,70 @@ public final class BookServiceCheck { private static final class InMemoryBookDao implements BookDao { private final Map books = new LinkedHashMap<>(); + private final Map categories = new LinkedHashMap<>(); private long nextId = 1L; + private long nextCategoryId = 2L; + + private InMemoryBookDao() { + categories.put(1L, category(1L, "Computer Science", "Programming books")); + } @Override public List findAllCategories() { - BookCategory category = new BookCategory(); - category.setId(1L); - category.setName("Computer Science"); - return Collections.singletonList(category); + List results = new ArrayList<>(); + for (BookCategory category : categories.values()) { + results.add(copy(category)); + } + return results; + } + + @Override + public Optional findCategoryById(long id) { + return Optional.ofNullable(categories.get(id)).map(this::copy); + } + + @Override + public Optional findCategoryByName(String name) { + for (BookCategory category : categories.values()) { + if (category.getName().equals(name)) { + return Optional.of(copy(category)); + } + } + return Optional.empty(); + } + + @Override + public long createCategory(BookCategory category) { + long id = nextCategoryId++; + BookCategory stored = copy(category); + stored.setId(id); + categories.put(id, stored); + return id; + } + + @Override + public boolean updateCategory(BookCategory category) { + if (!categories.containsKey(category.getId())) { + return false; + } + categories.put(category.getId(), copy(category)); + return true; + } + + @Override + public boolean deleteCategory(long id) { + return categories.remove(id) != null; + } + + @Override + public int countBooksByCategoryId(long categoryId) { + int count = 0; + for (Book book : books.values()) { + if (book.getCategoryId() == categoryId) { + count++; + } + } + return count; } @Override @@ -178,6 +274,10 @@ public final class BookServiceCheck { copy.setCategoryName(source.getCategoryName()); return copy; } + + private BookCategory copy(BookCategory source) { + return category(source.getId(), source.getName(), source.getDescription()); + } } private static final class FailingBookDao implements BookDao { @@ -186,6 +286,36 @@ public final class BookServiceCheck { throw new DaoException("Simulated category failure", null); } + @Override + public Optional findCategoryById(long id) { + throw new DaoException("Simulated category find failure", null); + } + + @Override + public Optional findCategoryByName(String name) { + throw new DaoException("Simulated category find failure", null); + } + + @Override + public long createCategory(BookCategory category) { + throw new DaoException("Simulated category create failure", null); + } + + @Override + public boolean updateCategory(BookCategory category) { + throw new DaoException("Simulated category update failure", null); + } + + @Override + public boolean deleteCategory(long id) { + throw new DaoException("Simulated category delete failure", null); + } + + @Override + public int countBooksByCategoryId(long categoryId) { + throw new DaoException("Simulated category count failure", null); + } + @Override public List search(BookSearchCriteria criteria) { throw new DaoException("Simulated search failure", null);