做读者档案、联系方式、借阅资格功能
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
package com.mzh.library.controller;
|
||||
|
||||
import com.mzh.library.dao.impl.JdbcReaderDao;
|
||||
import com.mzh.library.entity.AuthenticatedUser;
|
||||
import com.mzh.library.entity.Reader;
|
||||
import com.mzh.library.entity.ReaderSearchCriteria;
|
||||
import com.mzh.library.entity.ReaderStatus;
|
||||
import com.mzh.library.service.ReaderService;
|
||||
import com.mzh.library.service.ServiceResult;
|
||||
import com.mzh.library.service.impl.ReaderServiceImpl;
|
||||
import com.mzh.library.util.SessionAttributes;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
public class ReaderManagementServlet extends HttpServlet {
|
||||
private static final String MANAGE_JSP = "/WEB-INF/jsp/readers/manage.jsp";
|
||||
private static final String FORM_JSP = "/WEB-INF/jsp/readers/form.jsp";
|
||||
private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
|
||||
private static final String FLASH_SUCCESS = "flashSuccess";
|
||||
private static final String FLASH_ERROR = "flashError";
|
||||
|
||||
private ReaderService readerService;
|
||||
|
||||
@Override
|
||||
public void init() {
|
||||
this.readerService = new ReaderServiceImpl(new JdbcReaderDao());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
|
||||
String path = request.getServletPath();
|
||||
if ("/readers/new".equals(path)) {
|
||||
renderForm(request, response, "Create reader", "/readers", defaultReader(), Collections.emptyMap(),
|
||||
Collections.emptyMap(), null);
|
||||
return;
|
||||
}
|
||||
if ("/readers/edit".equals(path)) {
|
||||
showEditForm(request, response);
|
||||
return;
|
||||
}
|
||||
if (!"/readers".equals(path)) {
|
||||
response.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
|
||||
showManagementList(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
|
||||
String path = request.getServletPath();
|
||||
if ("/readers".equals(path)) {
|
||||
createReader(request, response);
|
||||
return;
|
||||
}
|
||||
if ("/readers/update".equals(path)) {
|
||||
updateReader(request, response);
|
||||
return;
|
||||
}
|
||||
if ("/readers/delete".equals(path)) {
|
||||
deactivateReader(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
response.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
}
|
||||
|
||||
private void showManagementList(HttpServletRequest request, HttpServletResponse response)
|
||||
throws ServletException, IOException {
|
||||
ReaderSearchCriteria criteria = searchCriteria(request);
|
||||
request.setAttribute("criteria", criteria);
|
||||
request.setAttribute("statuses", ReaderStatus.values());
|
||||
applyFlash(request);
|
||||
|
||||
ServiceResult<List<Reader>> searchResult = readerService.searchReaders(criteria);
|
||||
request.setAttribute("readers", searchResult.isSuccessful()
|
||||
? searchResult.getData()
|
||||
: Collections.emptyList());
|
||||
if (!searchResult.isSuccessful()) {
|
||||
request.setAttribute("errorMessage", searchResult.getMessage());
|
||||
request.setAttribute("errors", searchResult.getErrors());
|
||||
}
|
||||
|
||||
request.getRequestDispatcher(MANAGE_JSP).forward(request, response);
|
||||
}
|
||||
|
||||
private void showEditForm(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
|
||||
long id = requiredLong(request.getParameter("id"), -1L);
|
||||
ServiceResult<Optional<Reader>> result = readerService.findReader(id);
|
||||
if (!result.isSuccessful() || !result.getData().isPresent()) {
|
||||
flashError(request, result.isSuccessful() ? "Reader profile was not found." : result.getMessage());
|
||||
response.sendRedirect(request.getContextPath() + "/readers");
|
||||
return;
|
||||
}
|
||||
|
||||
renderForm(request, response, "Edit reader", "/readers/update", result.getData().get(),
|
||||
Collections.emptyMap(), Collections.emptyMap(), null);
|
||||
}
|
||||
|
||||
private void createReader(HttpServletRequest request, HttpServletResponse response)
|
||||
throws ServletException, IOException {
|
||||
ReaderForm form = readReaderForm(request, false);
|
||||
if (!form.getErrors().isEmpty()) {
|
||||
renderForm(request, response, "Create reader", "/readers", form.getReader(), form.getValues(),
|
||||
form.getErrors(), "Please correct the highlighted reader fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceResult<Long> result = readerService.createReader(currentUser(request), form.getReader());
|
||||
if (!result.isSuccessful()) {
|
||||
handleFormFailure(request, response, "Create reader", "/readers", form, result);
|
||||
return;
|
||||
}
|
||||
|
||||
flashSuccess(request, result.getMessage());
|
||||
response.sendRedirect(request.getContextPath() + "/readers");
|
||||
}
|
||||
|
||||
private void updateReader(HttpServletRequest request, HttpServletResponse response)
|
||||
throws ServletException, IOException {
|
||||
ReaderForm form = readReaderForm(request, true);
|
||||
if (!form.getErrors().isEmpty()) {
|
||||
renderForm(request, response, "Edit reader", "/readers/update", form.getReader(), form.getValues(),
|
||||
form.getErrors(), "Please correct the highlighted reader fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceResult<Void> result = readerService.updateReader(currentUser(request), form.getReader());
|
||||
if (!result.isSuccessful()) {
|
||||
handleFormFailure(request, response, "Edit reader", "/readers/update", form, result);
|
||||
return;
|
||||
}
|
||||
|
||||
flashSuccess(request, result.getMessage());
|
||||
response.sendRedirect(request.getContextPath() + "/readers");
|
||||
}
|
||||
|
||||
private void deactivateReader(HttpServletRequest request, HttpServletResponse response)
|
||||
throws IOException, ServletException {
|
||||
long id = requiredLong(request.getParameter("id"), -1L);
|
||||
ServiceResult<Void> result = readerService.deactivateReader(currentUser(request), id);
|
||||
if (isPermissionDenied(result)) {
|
||||
forwardDenied(request, response, result.getMessage());
|
||||
return;
|
||||
}
|
||||
if (result.isSuccessful()) {
|
||||
flashSuccess(request, result.getMessage());
|
||||
} else {
|
||||
flashError(request, result.getMessage());
|
||||
}
|
||||
response.sendRedirect(request.getContextPath() + "/readers");
|
||||
}
|
||||
|
||||
private void handleFormFailure(HttpServletRequest request, HttpServletResponse response, String title,
|
||||
String action, ReaderForm form, ServiceResult<?> result)
|
||||
throws ServletException, IOException {
|
||||
if (isPermissionDenied(result)) {
|
||||
forwardDenied(request, response, result.getMessage());
|
||||
return;
|
||||
}
|
||||
renderForm(request, response, title, action, form.getReader(), form.getValues(), result.getErrors(),
|
||||
result.getMessage());
|
||||
}
|
||||
|
||||
private void renderForm(HttpServletRequest request, HttpServletResponse response, String title, String action,
|
||||
Reader reader, Map<String, String> formValues, Map<String, String> errors,
|
||||
String errorMessage)
|
||||
throws ServletException, IOException {
|
||||
request.setAttribute("statuses", ReaderStatus.values());
|
||||
request.setAttribute("formTitle", title);
|
||||
request.setAttribute("formAction", action);
|
||||
request.setAttribute("reader", reader);
|
||||
request.setAttribute("formValues", formValues);
|
||||
request.setAttribute("errors", errors);
|
||||
if (errorMessage != null && !errorMessage.isEmpty()) {
|
||||
request.setAttribute("errorMessage", errorMessage);
|
||||
}
|
||||
request.getRequestDispatcher(FORM_JSP).forward(request, response);
|
||||
}
|
||||
|
||||
private ReaderForm readReaderForm(HttpServletRequest request, boolean requireId) {
|
||||
Map<String, String> values = formValues(request);
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
Reader reader = new Reader();
|
||||
|
||||
if (requireId) {
|
||||
reader.setId(parseLong(values.get("id"), "id", "Select a valid reader.", errors));
|
||||
}
|
||||
reader.setIdentifier(values.get("identifier"));
|
||||
reader.setUserId(optionalPositiveLong(values.get("userId"), "userId",
|
||||
"Enter a valid linked account ID.", errors));
|
||||
reader.setFullName(values.get("fullName"));
|
||||
reader.setPhone(values.get("phone"));
|
||||
reader.setEmail(values.get("email"));
|
||||
reader.setMaxBorrowCount(parseInt(values.get("maxBorrowCount"), "maxBorrowCount",
|
||||
"Enter a valid max borrow count.", errors));
|
||||
|
||||
try {
|
||||
reader.setStatus(ReaderStatus.fromCode(values.get("status")));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
errors.put("status", "Select a status.");
|
||||
}
|
||||
|
||||
return new ReaderForm(reader, values, errors);
|
||||
}
|
||||
|
||||
private Map<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("userId", trim(request.getParameter("userId")));
|
||||
values.put("fullName", trim(request.getParameter("fullName")));
|
||||
values.put("phone", trim(request.getParameter("phone")));
|
||||
values.put("email", trim(request.getParameter("email")));
|
||||
values.put("status", trim(request.getParameter("status")));
|
||||
values.put("maxBorrowCount", trim(request.getParameter("maxBorrowCount")));
|
||||
return values;
|
||||
}
|
||||
|
||||
private ReaderSearchCriteria searchCriteria(HttpServletRequest request) {
|
||||
return new ReaderSearchCriteria(
|
||||
request.getParameter("identifier"),
|
||||
request.getParameter("name"),
|
||||
request.getParameter("contact"),
|
||||
request.getParameter("status")
|
||||
);
|
||||
}
|
||||
|
||||
private Reader defaultReader() {
|
||||
Reader reader = new Reader();
|
||||
reader.setStatus(ReaderStatus.ACTIVE);
|
||||
reader.setMaxBorrowCount(5);
|
||||
return reader;
|
||||
}
|
||||
|
||||
private Long optionalPositiveLong(String value, String field, String message, Map<String, String> errors) {
|
||||
String trimmed = trim(value);
|
||||
if (trimmed.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
long parsed = Long.parseLong(trimmed);
|
||||
if (parsed <= 0) {
|
||||
errors.put(field, message);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (NumberFormatException ex) {
|
||||
errors.put(field, message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private long parseLong(String value, String field, String message, Map<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 readers.".equals(result.getMessage());
|
||||
}
|
||||
|
||||
private void forwardDenied(HttpServletRequest request, HttpServletResponse response, String message)
|
||||
throws ServletException, IOException {
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
request.setAttribute("errorMessage", message);
|
||||
request.getRequestDispatcher(UNAUTHORIZED_JSP).forward(request, response);
|
||||
}
|
||||
|
||||
private AuthenticatedUser currentUser(HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(false);
|
||||
Object value = session == null ? null : session.getAttribute(SessionAttributes.AUTHENTICATED_USER);
|
||||
return value instanceof AuthenticatedUser ? (AuthenticatedUser) value : null;
|
||||
}
|
||||
|
||||
private void applyFlash(HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
moveFlash(session, request, FLASH_SUCCESS, "successMessage");
|
||||
moveFlash(session, request, FLASH_ERROR, "errorMessage");
|
||||
}
|
||||
|
||||
private void moveFlash(HttpSession session, HttpServletRequest request, String sessionKey, String requestKey) {
|
||||
Object value = session.getAttribute(sessionKey);
|
||||
if (value != null) {
|
||||
request.setAttribute(requestKey, value);
|
||||
session.removeAttribute(sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void flashSuccess(HttpServletRequest request, String message) {
|
||||
request.getSession().setAttribute(FLASH_SUCCESS, message);
|
||||
}
|
||||
|
||||
private void flashError(HttpServletRequest request, String message) {
|
||||
request.getSession().setAttribute(FLASH_ERROR, message);
|
||||
}
|
||||
|
||||
private String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static final class ReaderForm {
|
||||
private final Reader reader;
|
||||
private final Map<String, String> values;
|
||||
private final Map<String, String> errors;
|
||||
|
||||
private ReaderForm(Reader reader, Map<String, String> values, Map<String, String> errors) {
|
||||
this.reader = reader;
|
||||
this.values = values;
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
private Reader getReader() {
|
||||
return reader;
|
||||
}
|
||||
|
||||
private Map<String, String> getValues() {
|
||||
return values;
|
||||
}
|
||||
|
||||
private Map<String, String> getErrors() {
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.mzh.library.dao;
|
||||
|
||||
import com.mzh.library.entity.Reader;
|
||||
import com.mzh.library.entity.ReaderSearchCriteria;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ReaderDao {
|
||||
List<Reader> search(ReaderSearchCriteria criteria);
|
||||
|
||||
Optional<Reader> findById(long id);
|
||||
|
||||
Optional<Reader> findByIdentifier(String identifier);
|
||||
|
||||
Optional<Reader> findByUserId(long userId);
|
||||
|
||||
long create(Reader reader);
|
||||
|
||||
boolean update(Reader reader);
|
||||
|
||||
boolean deactivate(long id);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.mzh.library.dao.impl;
|
||||
|
||||
import com.mzh.library.dao.ReaderDao;
|
||||
import com.mzh.library.entity.Reader;
|
||||
import com.mzh.library.entity.ReaderSearchCriteria;
|
||||
import com.mzh.library.entity.ReaderStatus;
|
||||
import com.mzh.library.exception.DaoException;
|
||||
import com.mzh.library.util.JdbcUtil;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Timestamp;
|
||||
import java.sql.Types;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class JdbcReaderDao implements ReaderDao {
|
||||
private static final String READER_COLUMNS = ""
|
||||
+ "r.id, r.reader_identifier, r.user_id, u.username, r.full_name, r.phone, r.email, "
|
||||
+ "r.status, r.max_borrow_count, r.created_at, r.updated_at ";
|
||||
|
||||
private static final String READER_FROM = ""
|
||||
+ "FROM readers r "
|
||||
+ "LEFT JOIN users u ON u.id = r.user_id ";
|
||||
|
||||
private static final String FIND_BY_ID = "SELECT " + READER_COLUMNS + READER_FROM + "WHERE r.id = ?";
|
||||
|
||||
private static final String FIND_BY_IDENTIFIER = "SELECT " + READER_COLUMNS + READER_FROM
|
||||
+ "WHERE r.reader_identifier = ?";
|
||||
|
||||
private static final String FIND_BY_USER_ID = "SELECT " + READER_COLUMNS + READER_FROM
|
||||
+ "WHERE r.user_id = ?";
|
||||
|
||||
private static final String CREATE = ""
|
||||
+ "INSERT INTO readers "
|
||||
+ "(reader_identifier, user_id, full_name, phone, email, status, max_borrow_count) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
private static final String UPDATE = ""
|
||||
+ "UPDATE readers "
|
||||
+ "SET reader_identifier = ?, user_id = ?, full_name = ?, phone = ?, email = ?, "
|
||||
+ "status = ?, max_borrow_count = ? "
|
||||
+ "WHERE id = ?";
|
||||
|
||||
private static final String DEACTIVATE = "UPDATE readers SET status = ? WHERE id = ?";
|
||||
|
||||
@Override
|
||||
public List<Reader> search(ReaderSearchCriteria criteria) {
|
||||
List<Object> parameters = new ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder("SELECT ")
|
||||
.append(READER_COLUMNS)
|
||||
.append(READER_FROM)
|
||||
.append("WHERE 1 = 1 ");
|
||||
|
||||
appendLike(sql, parameters, "r.reader_identifier", criteria.getIdentifier());
|
||||
appendLike(sql, parameters, "r.full_name", criteria.getName());
|
||||
appendContact(sql, parameters, criteria.getContact());
|
||||
if (criteria.getStatusCode() != null && !criteria.getStatusCode().isEmpty()) {
|
||||
sql.append("AND r.status = ? ");
|
||||
parameters.add(criteria.getStatusCode());
|
||||
}
|
||||
sql.append("ORDER BY r.full_name, r.reader_identifier");
|
||||
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql.toString())) {
|
||||
bind(statement, parameters);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
List<Reader> readers = new ArrayList<>();
|
||||
while (resultSet.next()) {
|
||||
readers.add(mapReader(resultSet));
|
||||
}
|
||||
return readers;
|
||||
}
|
||||
} catch (SQLException | IllegalArgumentException ex) {
|
||||
throw new DaoException("Unable to search readers", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findById(long id) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(FIND_BY_ID)) {
|
||||
statement.setLong(1, id);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? Optional.of(mapReader(resultSet)) : Optional.empty();
|
||||
}
|
||||
} catch (SQLException | IllegalArgumentException ex) {
|
||||
throw new DaoException("Unable to load reader by id", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findByIdentifier(String identifier) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(FIND_BY_IDENTIFIER)) {
|
||||
statement.setString(1, identifier);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? Optional.of(mapReader(resultSet)) : Optional.empty();
|
||||
}
|
||||
} catch (SQLException | IllegalArgumentException ex) {
|
||||
throw new DaoException("Unable to load reader by identifier", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findByUserId(long userId) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(FIND_BY_USER_ID)) {
|
||||
statement.setLong(1, userId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? Optional.of(mapReader(resultSet)) : Optional.empty();
|
||||
}
|
||||
} catch (SQLException | IllegalArgumentException ex) {
|
||||
throw new DaoException("Unable to load reader by user id", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long create(Reader reader) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(CREATE, Statement.RETURN_GENERATED_KEYS)) {
|
||||
bindReader(statement, reader);
|
||||
statement.executeUpdate();
|
||||
|
||||
try (ResultSet generatedKeys = statement.getGeneratedKeys()) {
|
||||
if (generatedKeys.next()) {
|
||||
return generatedKeys.getLong(1);
|
||||
}
|
||||
}
|
||||
throw new DaoException("Unable to read generated reader id", null);
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to create reader", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean update(Reader reader) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(UPDATE)) {
|
||||
bindReader(statement, reader);
|
||||
statement.setLong(8, reader.getId());
|
||||
return statement.executeUpdate() == 1;
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to update reader", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deactivate(long id) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(DEACTIVATE)) {
|
||||
statement.setString(1, ReaderStatus.INACTIVE.getCode());
|
||||
statement.setLong(2, id);
|
||||
return statement.executeUpdate() == 1;
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to deactivate reader", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendLike(StringBuilder sql, List<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 appendContact(StringBuilder sql, List<Object> parameters, String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
sql.append("AND (r.phone LIKE ? OR r.email LIKE ?) ");
|
||||
String filter = "%" + value.trim() + "%";
|
||||
parameters.add(filter);
|
||||
parameters.add(filter);
|
||||
}
|
||||
|
||||
private void bind(PreparedStatement statement, List<Object> parameters) throws SQLException {
|
||||
for (int i = 0; i < parameters.size(); i++) {
|
||||
Object value = parameters.get(i);
|
||||
statement.setString(i + 1, value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private void bindReader(PreparedStatement statement, Reader reader) throws SQLException {
|
||||
statement.setString(1, reader.getIdentifier());
|
||||
if (reader.getUserId() == null) {
|
||||
statement.setNull(2, Types.BIGINT);
|
||||
} else {
|
||||
statement.setLong(2, reader.getUserId());
|
||||
}
|
||||
statement.setString(3, reader.getFullName());
|
||||
statement.setString(4, reader.getPhone());
|
||||
statement.setString(5, reader.getEmail());
|
||||
statement.setString(6, reader.getStatus().getCode());
|
||||
statement.setInt(7, reader.getMaxBorrowCount());
|
||||
}
|
||||
|
||||
private Reader mapReader(ResultSet resultSet) throws SQLException {
|
||||
Reader reader = new Reader();
|
||||
reader.setId(resultSet.getLong("id"));
|
||||
reader.setIdentifier(resultSet.getString("reader_identifier"));
|
||||
long userId = resultSet.getLong("user_id");
|
||||
reader.setUserId(resultSet.wasNull() ? null : userId);
|
||||
reader.setUsername(resultSet.getString("username"));
|
||||
reader.setFullName(resultSet.getString("full_name"));
|
||||
reader.setPhone(resultSet.getString("phone"));
|
||||
reader.setEmail(resultSet.getString("email"));
|
||||
reader.setStatus(ReaderStatus.fromCode(resultSet.getString("status")));
|
||||
reader.setMaxBorrowCount(resultSet.getInt("max_borrow_count"));
|
||||
reader.setCreatedAt(toLocalDateTime(resultSet.getTimestamp("created_at")));
|
||||
reader.setUpdatedAt(toLocalDateTime(resultSet.getTimestamp("updated_at")));
|
||||
return reader;
|
||||
}
|
||||
|
||||
private LocalDateTime toLocalDateTime(Timestamp timestamp) {
|
||||
return timestamp == null ? null : timestamp.toLocalDateTime();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.mzh.library.entity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public class Reader {
|
||||
private long id;
|
||||
private String identifier;
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String fullName;
|
||||
private String phone;
|
||||
private String email;
|
||||
private ReaderStatus status;
|
||||
private int maxBorrowCount;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getIdentifier() {
|
||||
return identifier;
|
||||
}
|
||||
|
||||
public void setIdentifier(String identifier) {
|
||||
this.identifier = identifier;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public ReaderStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(ReaderStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public int getMaxBorrowCount() {
|
||||
return maxBorrowCount;
|
||||
}
|
||||
|
||||
public void setMaxBorrowCount(int maxBorrowCount) {
|
||||
this.maxBorrowCount = maxBorrowCount;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.mzh.library.entity;
|
||||
|
||||
public class ReaderSearchCriteria {
|
||||
private String identifier;
|
||||
private String name;
|
||||
private String contact;
|
||||
private String statusCode;
|
||||
|
||||
public ReaderSearchCriteria() {
|
||||
}
|
||||
|
||||
public ReaderSearchCriteria(String identifier, String name, String contact, String statusCode) {
|
||||
this.identifier = trim(identifier);
|
||||
this.name = trim(name);
|
||||
this.contact = trim(contact);
|
||||
this.statusCode = trim(statusCode);
|
||||
}
|
||||
|
||||
public String getIdentifier() {
|
||||
return identifier;
|
||||
}
|
||||
|
||||
public void setIdentifier(String identifier) {
|
||||
this.identifier = trim(identifier);
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = trim(name);
|
||||
}
|
||||
|
||||
public String getContact() {
|
||||
return contact;
|
||||
}
|
||||
|
||||
public void setContact(String contact) {
|
||||
this.contact = trim(contact);
|
||||
}
|
||||
|
||||
public String getStatusCode() {
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
public void setStatusCode(String statusCode) {
|
||||
this.statusCode = trim(statusCode);
|
||||
}
|
||||
|
||||
private String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.mzh.library.entity;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public enum ReaderStatus {
|
||||
ACTIVE("active", "Active"),
|
||||
SUSPENDED("suspended", "Suspended"),
|
||||
INACTIVE("inactive", "Inactive");
|
||||
|
||||
private final String code;
|
||||
private final String displayName;
|
||||
|
||||
ReaderStatus(String code, String displayName) {
|
||||
this.code = code;
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public static ReaderStatus fromCode(String code) {
|
||||
if (code == null || code.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("Reader status is required");
|
||||
}
|
||||
|
||||
String normalized = code.trim().toLowerCase(Locale.ROOT);
|
||||
for (ReaderStatus status : values()) {
|
||||
if (status.code.equals(normalized)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Unsupported reader status: " + code);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public class AuthorizationFilter implements Filter {
|
||||
private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
|
||||
private static final List<PathRule> RULES = Arrays.asList(
|
||||
new PathRule("/books", Permission.MANAGE_BOOKS),
|
||||
new PathRule("/readers", Permission.MANAGE_READERS),
|
||||
new PathRule("/catalog", Permission.VIEW_CATALOG),
|
||||
new PathRule("/admin", Permission.MANAGE_USERS),
|
||||
new PathRule("/librarian", Permission.MANAGE_BORROWING),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.mzh.library.service;
|
||||
|
||||
import com.mzh.library.entity.AuthenticatedUser;
|
||||
import com.mzh.library.entity.Reader;
|
||||
import com.mzh.library.entity.ReaderSearchCriteria;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ReaderService {
|
||||
ServiceResult<List<Reader>> searchReaders(ReaderSearchCriteria criteria);
|
||||
|
||||
ServiceResult<Optional<Reader>> findReader(long id);
|
||||
|
||||
ServiceResult<Long> createReader(AuthenticatedUser actor, Reader reader);
|
||||
|
||||
ServiceResult<Void> updateReader(AuthenticatedUser actor, Reader reader);
|
||||
|
||||
ServiceResult<Void> deactivateReader(AuthenticatedUser actor, long id);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.mzh.library.service.impl;
|
||||
|
||||
import com.mzh.library.dao.ReaderDao;
|
||||
import com.mzh.library.entity.AuthenticatedUser;
|
||||
import com.mzh.library.entity.Permission;
|
||||
import com.mzh.library.entity.Reader;
|
||||
import com.mzh.library.entity.ReaderSearchCriteria;
|
||||
import com.mzh.library.entity.ReaderStatus;
|
||||
import com.mzh.library.exception.DaoException;
|
||||
import com.mzh.library.service.PermissionPolicy;
|
||||
import com.mzh.library.service.ReaderService;
|
||||
import com.mzh.library.service.ServiceResult;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class ReaderServiceImpl implements ReaderService {
|
||||
private static final Logger LOGGER = Logger.getLogger(ReaderServiceImpl.class.getName());
|
||||
private static final String UNAVAILABLE_MESSAGE =
|
||||
"Reader service is temporarily unavailable. Please try again later.";
|
||||
private static final String VALIDATION_MESSAGE = "Please correct the highlighted reader fields.";
|
||||
private static final String SEARCH_VALIDATION_MESSAGE = "Please correct the reader search filters.";
|
||||
private static final String DENIED_MESSAGE = "You do not have permission to manage readers.";
|
||||
private static final int MAX_BORROW_LIMIT = 50;
|
||||
private static final Pattern PHONE_PATTERN = Pattern.compile("(?=.*\\d)[0-9+()\\-\\s]{6,32}");
|
||||
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
|
||||
|
||||
private final ReaderDao readerDao;
|
||||
private final PermissionPolicy permissionPolicy;
|
||||
|
||||
public ReaderServiceImpl(ReaderDao readerDao) {
|
||||
this(readerDao, new PermissionPolicy());
|
||||
}
|
||||
|
||||
public ReaderServiceImpl(ReaderDao readerDao, PermissionPolicy permissionPolicy) {
|
||||
this.readerDao = readerDao;
|
||||
this.permissionPolicy = permissionPolicy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<List<Reader>> searchReaders(ReaderSearchCriteria criteria) {
|
||||
ReaderSearchCriteria normalized = criteria == null ? new ReaderSearchCriteria() : criteria;
|
||||
Map<String, String> errors = validateSearch(normalized);
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure(SEARCH_VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
|
||||
try {
|
||||
return ServiceResult.success(readerDao.search(normalized));
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to search readers", ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Optional<Reader>> findReader(long id) {
|
||||
if (id <= 0) {
|
||||
return ServiceResult.failure("Select a valid reader.");
|
||||
}
|
||||
|
||||
try {
|
||||
return ServiceResult.success(readerDao.findById(id));
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to load reader id=" + id, ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Long> createReader(AuthenticatedUser actor, Reader reader) {
|
||||
if (!canManageReaders(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
|
||||
normalize(reader);
|
||||
Map<String, String> errors = validate(reader, false);
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
|
||||
try {
|
||||
if (readerDao.findByIdentifier(reader.getIdentifier()).isPresent()) {
|
||||
errors.put("identifier", "Reader identifier is already in use.");
|
||||
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
if (reader.getUserId() != null && readerDao.findByUserId(reader.getUserId()).isPresent()) {
|
||||
errors.put("userId", "Linked account is already assigned to a reader profile.");
|
||||
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
|
||||
long id = readerDao.create(reader);
|
||||
LOGGER.info("Created reader id=" + id + " actorId=" + actor.getId());
|
||||
return ServiceResult.success(id, "Reader profile created.");
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to create reader actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Void> updateReader(AuthenticatedUser actor, Reader reader) {
|
||||
if (!canManageReaders(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
|
||||
normalize(reader);
|
||||
Map<String, String> errors = validate(reader, true);
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
|
||||
try {
|
||||
Optional<Reader> existingWithIdentifier = readerDao.findByIdentifier(reader.getIdentifier());
|
||||
if (existingWithIdentifier.isPresent() && existingWithIdentifier.get().getId() != reader.getId()) {
|
||||
errors.put("identifier", "Reader identifier is already in use.");
|
||||
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
if (reader.getUserId() != null) {
|
||||
Optional<Reader> existingWithUser = readerDao.findByUserId(reader.getUserId());
|
||||
if (existingWithUser.isPresent() && existingWithUser.get().getId() != reader.getId()) {
|
||||
errors.put("userId", "Linked account is already assigned to a reader profile.");
|
||||
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
}
|
||||
|
||||
if (!readerDao.update(reader)) {
|
||||
return ServiceResult.failure("Reader profile was not found.");
|
||||
}
|
||||
|
||||
LOGGER.info("Updated reader id=" + reader.getId() + " actorId=" + actor.getId());
|
||||
return ServiceResult.success(null, "Reader profile updated.");
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to update reader id=" + reader.getId() + " actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Void> deactivateReader(AuthenticatedUser actor, long id) {
|
||||
if (!canManageReaders(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
if (id <= 0) {
|
||||
return ServiceResult.failure("Select a valid reader.");
|
||||
}
|
||||
|
||||
try {
|
||||
if (!readerDao.deactivate(id)) {
|
||||
return ServiceResult.failure("Reader profile was not found.");
|
||||
}
|
||||
|
||||
LOGGER.info("Deactivated reader id=" + id + " actorId=" + actor.getId());
|
||||
return ServiceResult.success(null, "Reader profile deactivated.");
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to deactivate reader id=" + id + " actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canManageReaders(AuthenticatedUser actor) {
|
||||
return actor != null && permissionPolicy.allows(actor.getRole(), Permission.MANAGE_READERS);
|
||||
}
|
||||
|
||||
private void normalize(Reader reader) {
|
||||
if (reader == null) {
|
||||
return;
|
||||
}
|
||||
reader.setIdentifier(trim(reader.getIdentifier()));
|
||||
reader.setFullName(trim(reader.getFullName()));
|
||||
reader.setPhone(trim(reader.getPhone()));
|
||||
reader.setEmail(trim(reader.getEmail()));
|
||||
}
|
||||
|
||||
private Map<String, String> validateSearch(ReaderSearchCriteria criteria) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
if (criteria.getStatusCode() != null && !criteria.getStatusCode().isEmpty()) {
|
||||
try {
|
||||
criteria.setStatusCode(ReaderStatus.fromCode(criteria.getStatusCode()).getCode());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
errors.put("status", "Select a valid status.");
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private Map<String, String> validate(Reader reader, boolean requireId) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
if (reader == null) {
|
||||
errors.put("reader", "Reader details are required.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (requireId && reader.getId() <= 0) {
|
||||
errors.put("id", "Select a valid reader.");
|
||||
}
|
||||
requireLength(errors, "identifier", reader.getIdentifier(), "Reader identifier", 64);
|
||||
requireLength(errors, "fullName", reader.getFullName(), "Full name", 100);
|
||||
if (reader.getUserId() != null && reader.getUserId() <= 0) {
|
||||
errors.put("userId", "Linked account ID must be positive.");
|
||||
}
|
||||
validateContact(errors, reader);
|
||||
if (reader.getStatus() == null) {
|
||||
errors.put("status", "Select a status.");
|
||||
}
|
||||
if (reader.getMaxBorrowCount() < 1 || reader.getMaxBorrowCount() > MAX_BORROW_LIMIT) {
|
||||
errors.put("maxBorrowCount", "Max borrow count must be between 1 and " + MAX_BORROW_LIMIT + ".");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private void validateContact(Map<String, String> errors, Reader reader) {
|
||||
String phone = reader.getPhone();
|
||||
String email = reader.getEmail();
|
||||
if ((phone == null || phone.isEmpty()) && (email == null || email.isEmpty())) {
|
||||
errors.put("phone", "Phone or email is required.");
|
||||
return;
|
||||
}
|
||||
if (phone != null && !phone.isEmpty() && !PHONE_PATTERN.matcher(phone).matches()) {
|
||||
errors.put("phone", "Phone must include a digit and use 6 to 32 digits or common phone symbols.");
|
||||
}
|
||||
if (email != null && !email.isEmpty() && !EMAIL_PATTERN.matcher(email).matches()) {
|
||||
errors.put("email", "Email must be a valid address.");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireLength(Map<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();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,31 @@ CREATE TABLE IF NOT EXISTS system_logs (
|
||||
KEY idx_system_logs_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS readers (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
reader_identifier VARCHAR(64) NOT NULL,
|
||||
user_id BIGINT NULL,
|
||||
full_name VARCHAR(100) NOT NULL,
|
||||
phone VARCHAR(32) NULL,
|
||||
email VARCHAR(120) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
max_borrow_count INT NOT NULL DEFAULT 5,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_readers_identifier (reader_identifier),
|
||||
UNIQUE KEY uk_readers_user_id (user_id),
|
||||
KEY idx_readers_full_name (full_name),
|
||||
KEY idx_readers_phone (phone),
|
||||
KEY idx_readers_email (email),
|
||||
KEY idx_readers_status (status),
|
||||
CONSTRAINT fk_readers_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
CONSTRAINT chk_readers_status
|
||||
CHECK (status IN ('active', 'suspended', 'inactive')),
|
||||
CONSTRAINT chk_readers_max_borrow_count
|
||||
CHECK (max_borrow_count BETWEEN 1 AND 50)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS book_categories (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(96) NOT NULL,
|
||||
@@ -137,6 +162,18 @@ INSERT IGNORE INTO users (username, password_hash, display_name, role_code, acti
|
||||
('librarian', 'pbkdf2_sha256$60000$bXpoLWxpYnJhcmlhbi1kZW1vLXNhbHQ=$StIdJGDRIiF4aCr+qKuwvob5sL3+6j1caF2sQNqFi78=', 'Library Staff', 'librarian', 1),
|
||||
('reader', 'pbkdf2_sha256$60000$bXpoLXJlYWRlci1kZW1vLXNhbHQ=$iaiZPGhaIQ+2R2o9UQRj6wsrmYSJ4efqS3jCzM/XU7g=', 'Demo Reader', 'reader', 1);
|
||||
|
||||
INSERT INTO readers (reader_identifier, user_id, full_name, phone, email, status, max_borrow_count) VALUES
|
||||
('RD-0001', (SELECT id FROM users WHERE username = 'reader'), 'Demo Reader', '13800000000',
|
||||
'reader@example.com', 'active', 5),
|
||||
('RD-0002', NULL, 'Suspended Reader', '13900000000', 'suspended.reader@example.com', 'suspended', 3)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
user_id = VALUES(user_id),
|
||||
full_name = VALUES(full_name),
|
||||
phone = VALUES(phone),
|
||||
email = VALUES(email),
|
||||
status = VALUES(status),
|
||||
max_borrow_count = VALUES(max_borrow_count);
|
||||
|
||||
INSERT INTO book_categories (name, description) VALUES
|
||||
('Computer Science', 'Programming, software engineering, and systems books'),
|
||||
('Literature', 'Classic and modern literature'),
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
|
||||
<a href="${pageContext.request.contextPath}/librarian/home">Librarian</a>
|
||||
<a href="${pageContext.request.contextPath}/books">Books</a>
|
||||
<a href="${pageContext.request.contextPath}/readers">Readers</a>
|
||||
</c:if>
|
||||
<a href="${pageContext.request.contextPath}/reader/home">Reader</a>
|
||||
<span class="user-pill">
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
<p>Create, update, delete, and review book inventory records.</p>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Open</a>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card">
|
||||
<h2>Reader Management</h2>
|
||||
<p>Create, update, deactivate, and review reader eligibility records.</p>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/readers">Open</a>
|
||||
</article>
|
||||
</c:if>
|
||||
|
||||
<article class="workspace-card">
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
|
||||
<!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="reader-form-title">
|
||||
<p class="eyebrow">Reader Management</p>
|
||||
<h1 id="reader-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 : reader.identifier}" />
|
||||
<c:set var="userIdValue" value="${hasFormValues ? formValues.userId : reader.userId}" />
|
||||
<c:set var="fullNameValue" value="${hasFormValues ? formValues.fullName : reader.fullName}" />
|
||||
<c:set var="phoneValue" value="${hasFormValues ? formValues.phone : reader.phone}" />
|
||||
<c:set var="emailValue" value="${hasFormValues ? formValues.email : reader.email}" />
|
||||
<c:set var="statusValue" value="${hasFormValues ? formValues.status : reader.status.code}" />
|
||||
<c:set var="maxBorrowCountValue" value="${hasFormValues ? formValues.maxBorrowCount : reader.maxBorrowCount}" />
|
||||
|
||||
<form class="reader-form" action="${pageContext.request.contextPath}${formAction}" method="post" novalidate>
|
||||
<c:if test="${reader.id > 0}">
|
||||
<input type="hidden" name="id" value="${reader.id}">
|
||||
</c:if>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label for="identifier">Reader 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="fullName">Full name</label>
|
||||
<input id="fullName" name="fullName" type="text" value="${fn:escapeXml(fullNameValue)}" required>
|
||||
<c:if test="${not empty errors.fullName}">
|
||||
<span class="field-error"><c:out value="${errors.fullName}" /></span>
|
||||
</c:if>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="phone">Phone</label>
|
||||
<input id="phone" name="phone" type="tel" value="${fn:escapeXml(phoneValue)}">
|
||||
<c:if test="${not empty errors.phone}">
|
||||
<span class="field-error"><c:out value="${errors.phone}" /></span>
|
||||
</c:if>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" value="${fn:escapeXml(emailValue)}">
|
||||
<c:if test="${not empty errors.email}">
|
||||
<span class="field-error"><c:out value="${errors.email}" /></span>
|
||||
</c:if>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="userId">Linked account ID</label>
|
||||
<input id="userId" name="userId" type="number" min="1" value="${fn:escapeXml(userIdValue)}">
|
||||
<c:if test="${not empty errors.userId}">
|
||||
<span class="field-error"><c:out value="${errors.userId}" /></span>
|
||||
</c:if>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="maxBorrowCount">Max borrow count</label>
|
||||
<input id="maxBorrowCount" name="maxBorrowCount" type="number" min="1" max="50"
|
||||
value="${fn:escapeXml(maxBorrowCountValue)}" required>
|
||||
<c:if test="${not empty errors.maxBorrowCount}">
|
||||
<span class="field-error"><c:out value="${errors.maxBorrowCount}" /></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}/readers">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,139 @@
|
||||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Manage Readers - 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-readers-title">
|
||||
<p class="eyebrow">Reader Management</p>
|
||||
<h1 id="manage-readers-title">Manage readers</h1>
|
||||
<p>Create, update, and review reader eligibility and contact records.</p>
|
||||
<a class="button button-primary" href="${pageContext.request.contextPath}/readers/new">New reader</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="Reader management search">
|
||||
<form class="search-form" action="${pageContext.request.contextPath}/readers" method="get">
|
||||
<div class="search-field">
|
||||
<label for="identifier">Reader ID</label>
|
||||
<input id="identifier" name="identifier" type="text" value="${fn:escapeXml(criteria.identifier)}">
|
||||
</div>
|
||||
|
||||
<div class="search-field">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" value="${fn:escapeXml(criteria.name)}">
|
||||
</div>
|
||||
|
||||
<div class="search-field">
|
||||
<label for="contact">Phone or email</label>
|
||||
<input id="contact" name="contact" type="text" value="${fn:escapeXml(criteria.contact)}">
|
||||
</div>
|
||||
|
||||
<div class="search-field">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<option value="">All statuses</option>
|
||||
<c:forEach var="status" items="${statuses}">
|
||||
<option value="${status.code}" <c:if test="${criteria.statusCode == status.code}">selected</c:if>>
|
||||
<c:out value="${status.displayName}" />
|
||||
</option>
|
||||
</c:forEach>
|
||||
</select>
|
||||
<c:if test="${not empty errors.status}">
|
||||
<span class="field-error"><c:out value="${errors.status}" /></span>
|
||||
</c:if>
|
||||
</div>
|
||||
|
||||
<button class="button button-primary" type="submit">Search</button>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/readers">Clear</a>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="table-panel" aria-labelledby="reader-results-title">
|
||||
<h2 id="reader-results-title">Reader records</h2>
|
||||
<c:choose>
|
||||
<c:when test="${empty readers}">
|
||||
<p class="empty-state">No reader records match the current filters.</p>
|
||||
</c:when>
|
||||
<c:otherwise>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Reader ID</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Contact</th>
|
||||
<th scope="col">Account</th>
|
||||
<th scope="col">Borrow limit</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<c:forEach var="reader" items="${readers}">
|
||||
<tr>
|
||||
<td><c:out value="${reader.identifier}" /></td>
|
||||
<td><c:out value="${reader.fullName}" /></td>
|
||||
<td>
|
||||
<c:if test="${not empty reader.phone}">
|
||||
<div><c:out value="${reader.phone}" /></div>
|
||||
</c:if>
|
||||
<c:if test="${not empty reader.email}">
|
||||
<div><c:out value="${reader.email}" /></div>
|
||||
</c:if>
|
||||
</td>
|
||||
<td>
|
||||
<c:choose>
|
||||
<c:when test="${not empty reader.username}">
|
||||
<c:out value="${reader.username}" />
|
||||
</c:when>
|
||||
<c:otherwise>Unlinked</c:otherwise>
|
||||
</c:choose>
|
||||
</td>
|
||||
<td><c:out value="${reader.maxBorrowCount}" /></td>
|
||||
<td>
|
||||
<span class="status-pill status-${reader.status.code}">
|
||||
<c:out value="${reader.status.displayName}" />
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-actions">
|
||||
<a class="button button-secondary"
|
||||
href="${pageContext.request.contextPath}/readers/edit?id=${reader.id}">Edit</a>
|
||||
<form action="${pageContext.request.contextPath}/readers/delete"
|
||||
method="post"
|
||||
onsubmit="return confirm('Deactivate this reader profile?');">
|
||||
<input type="hidden" name="id" value="${reader.id}">
|
||||
<button class="button button-danger" type="submit">Deactivate</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</c:forEach>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</c:otherwise>
|
||||
</c:choose>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -33,6 +33,12 @@
|
||||
<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>
|
||||
|
||||
<article class="workspace-card">
|
||||
<h2>Reader Management</h2>
|
||||
<p>Create, update, deactivate, and review eligibility fields for reader records.</p>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/readers">Manage readers</a>
|
||||
</article>
|
||||
</c:if>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -97,6 +97,19 @@
|
||||
<url-pattern>/books/delete</url-pattern>
|
||||
</servlet-mapping>
|
||||
|
||||
<servlet>
|
||||
<servlet-name>ReaderManagementServlet</servlet-name>
|
||||
<servlet-class>com.mzh.library.controller.ReaderManagementServlet</servlet-class>
|
||||
</servlet>
|
||||
<servlet-mapping>
|
||||
<servlet-name>ReaderManagementServlet</servlet-name>
|
||||
<url-pattern>/readers</url-pattern>
|
||||
<url-pattern>/readers/new</url-pattern>
|
||||
<url-pattern>/readers/edit</url-pattern>
|
||||
<url-pattern>/readers/update</url-pattern>
|
||||
<url-pattern>/readers/delete</url-pattern>
|
||||
</servlet-mapping>
|
||||
|
||||
<servlet>
|
||||
<servlet-name>UnauthorizedServlet</servlet-name>
|
||||
<servlet-class>com.mzh.library.controller.UnauthorizedServlet</servlet-class>
|
||||
|
||||
@@ -303,7 +303,9 @@ h2 {
|
||||
.search-form input,
|
||||
.search-form select,
|
||||
.book-form input,
|
||||
.book-form select {
|
||||
.book-form select,
|
||||
.reader-form input,
|
||||
.reader-form select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 9px 11px;
|
||||
@@ -316,7 +318,9 @@ h2 {
|
||||
.search-form input:focus,
|
||||
.search-form select:focus,
|
||||
.book-form input:focus,
|
||||
.book-form select:focus {
|
||||
.book-form select:focus,
|
||||
.reader-form input:focus,
|
||||
.reader-form select:focus {
|
||||
outline: 3px solid rgba(37, 111, 108, 0.18);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
@@ -378,6 +382,21 @@ h2 {
|
||||
background: #eef1f5;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
color: var(--color-success);
|
||||
background: #edf8ef;
|
||||
}
|
||||
|
||||
.status-suspended {
|
||||
color: var(--color-warning);
|
||||
background: #fff7e5;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
color: var(--color-muted);
|
||||
background: #eef1f5;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -393,7 +412,8 @@ h2 {
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.book-form {
|
||||
.book-form,
|
||||
.reader-form {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ public final class PermissionPolicyCheck {
|
||||
|
||||
require(policy.allows(Role.ADMINISTRATOR, Permission.MANAGE_USERS), "administrator should manage users");
|
||||
require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_BORROWING), "librarian should manage borrowing");
|
||||
require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_READERS), "librarian should manage readers");
|
||||
require(!policy.allows(Role.LIBRARIAN, Permission.MANAGE_USERS), "librarian should not manage users");
|
||||
require(policy.allows(Role.READER, Permission.VIEW_CATALOG), "reader should view catalog");
|
||||
require(!policy.allows(Role.READER, Permission.MANAGE_BOOKS), "reader should not manage books");
|
||||
require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers");
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package com.mzh.library.service;
|
||||
|
||||
import com.mzh.library.dao.ReaderDao;
|
||||
import com.mzh.library.entity.AuthenticatedUser;
|
||||
import com.mzh.library.entity.Permission;
|
||||
import com.mzh.library.entity.Reader;
|
||||
import com.mzh.library.entity.ReaderSearchCriteria;
|
||||
import com.mzh.library.entity.ReaderStatus;
|
||||
import com.mzh.library.entity.Role;
|
||||
import com.mzh.library.exception.DaoException;
|
||||
import com.mzh.library.service.impl.ReaderServiceImpl;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public final class ReaderServiceCheck {
|
||||
private static final String UNAVAILABLE_MESSAGE =
|
||||
"Reader service is temporarily unavailable. Please try again later.";
|
||||
|
||||
private ReaderServiceCheck() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
Logger.getLogger(ReaderServiceImpl.class.getName()).setLevel(Level.OFF);
|
||||
|
||||
InMemoryReaderDao dao = new InMemoryReaderDao();
|
||||
ReaderService service = new ReaderServiceImpl(dao);
|
||||
AuthenticatedUser librarian = user(10L, Role.LIBRARIAN);
|
||||
AuthenticatedUser readerUser = user(20L, Role.READER);
|
||||
|
||||
ServiceResult<Long> invalidContact = service.createReader(librarian,
|
||||
reader(0L, "RD-1000", null, "Invalid Contact", "bad", "", ReaderStatus.ACTIVE, 5));
|
||||
require(!invalidContact.isSuccessful(), "invalid contact should fail");
|
||||
require(invalidContact.getErrors().containsKey("phone"), "invalid phone should target phone field");
|
||||
|
||||
ServiceResult<Long> symbolsOnlyPhone = service.createReader(librarian,
|
||||
reader(0L, "RD-1009", null, "Symbols Only Phone", "------", "", ReaderStatus.ACTIVE, 5));
|
||||
require(!symbolsOnlyPhone.isSuccessful(), "phone without digits should fail");
|
||||
require(symbolsOnlyPhone.getErrors().containsKey("phone"), "phone without digits should target phone field");
|
||||
|
||||
ServiceResult<Long> missingContact = service.createReader(librarian,
|
||||
reader(0L, "RD-1008", null, "Missing Contact", "", "", ReaderStatus.ACTIVE, 5));
|
||||
require(!missingContact.isSuccessful(), "missing contact should fail");
|
||||
require(missingContact.getErrors().containsKey("phone"), "missing contact should target phone field");
|
||||
|
||||
ServiceResult<Long> invalidBorrowLimit = service.createReader(librarian,
|
||||
reader(0L, "RD-1001", null, "Invalid Limit", "13800000001", "", ReaderStatus.ACTIVE, 0));
|
||||
require(!invalidBorrowLimit.isSuccessful(), "invalid borrow limit should fail");
|
||||
require(invalidBorrowLimit.getErrors().containsKey("maxBorrowCount"),
|
||||
"invalid borrow limit should target maxBorrowCount field");
|
||||
|
||||
ServiceResult<Long> invalidUserId = service.createReader(librarian,
|
||||
reader(0L, "RD-1002", -1L, "Invalid User", "13800000002", "", ReaderStatus.ACTIVE, 5));
|
||||
require(!invalidUserId.isSuccessful(), "invalid linked account should fail");
|
||||
require(invalidUserId.getErrors().containsKey("userId"),
|
||||
"invalid linked account should target userId field");
|
||||
|
||||
ServiceResult<Long> denied = service.createReader(readerUser,
|
||||
reader(0L, "RD-1006", null, "Reader Write", "13800000007", "", ReaderStatus.ACTIVE, 5));
|
||||
require(!denied.isSuccessful(), "reader write should fail");
|
||||
require("You do not have permission to manage readers.".equals(denied.getMessage()),
|
||||
"reader write should return permission message");
|
||||
|
||||
ServiceResult<Long> created = service.createReader(librarian,
|
||||
reader(0L, "RD-1003", 30L, "Service Reader", "13800000003",
|
||||
"service.reader@example.com", ReaderStatus.ACTIVE, 5));
|
||||
require(created.isSuccessful(), "librarian should create a valid reader");
|
||||
long createdId = created.getData();
|
||||
|
||||
ServiceResult<Long> duplicateIdentifier = service.createReader(librarian,
|
||||
reader(0L, "RD-1003", null, "Duplicate Identifier", "13800000004", "", ReaderStatus.ACTIVE, 5));
|
||||
require(!duplicateIdentifier.isSuccessful(), "duplicate reader identifier should fail");
|
||||
require(duplicateIdentifier.getErrors().containsKey("identifier"),
|
||||
"duplicate identifier should target identifier field");
|
||||
|
||||
ServiceResult<Long> duplicateUser = service.createReader(librarian,
|
||||
reader(0L, "RD-1004", 30L, "Duplicate User", "13800000005", "", ReaderStatus.ACTIVE, 5));
|
||||
require(!duplicateUser.isSuccessful(), "duplicate linked account should fail");
|
||||
require(duplicateUser.getErrors().containsKey("userId"), "duplicate linked account should target userId field");
|
||||
|
||||
ServiceResult<Void> updated = service.updateReader(librarian,
|
||||
reader(createdId, "RD-1005", 30L, "Updated Reader", "13800000006",
|
||||
"updated.reader@example.com", ReaderStatus.SUSPENDED, 3));
|
||||
require(updated.isSuccessful(), "librarian should update a valid reader");
|
||||
require(dao.findById(createdId).get().getStatus() == ReaderStatus.SUSPENDED,
|
||||
"update should persist reader status");
|
||||
|
||||
ServiceResult<List<Reader>> search = service.searchReaders(
|
||||
new ReaderSearchCriteria("RD-1005", "Updated", "updated.reader", "suspended"));
|
||||
require(search.isSuccessful(), "search should succeed");
|
||||
require(search.getData().size() == 1, "search should find updated reader by filters");
|
||||
|
||||
ServiceResult<List<Reader>> invalidSearch = service.searchReaders(
|
||||
new ReaderSearchCriteria("", "", "", "missing"));
|
||||
require(!invalidSearch.isSuccessful(), "invalid search status should fail");
|
||||
require(invalidSearch.getErrors().containsKey("status"), "invalid search status should target status field");
|
||||
|
||||
ServiceResult<Void> deactivated = service.deactivateReader(librarian, createdId);
|
||||
require(deactivated.isSuccessful(), "librarian should deactivate a reader");
|
||||
require(dao.findById(createdId).get().getStatus() == ReaderStatus.INACTIVE,
|
||||
"deactivate should mark the reader inactive");
|
||||
|
||||
ReaderService failingService = new ReaderServiceImpl(new FailingReaderDao());
|
||||
ServiceResult<List<Reader>> unavailable = failingService.searchReaders(new ReaderSearchCriteria());
|
||||
require(!unavailable.isSuccessful(), "DAO failure should not escape service");
|
||||
require(UNAVAILABLE_MESSAGE.equals(unavailable.getMessage()), "search DAO failure should map to safe message");
|
||||
|
||||
ServiceResult<Long> unavailableCreate = failingService.createReader(librarian,
|
||||
reader(0L, "RD-1007", null, "Unavailable Create", "13800000008", "", ReaderStatus.ACTIVE, 5));
|
||||
require(!unavailableCreate.isSuccessful(), "create DAO failure should not escape service");
|
||||
require(UNAVAILABLE_MESSAGE.equals(unavailableCreate.getMessage()),
|
||||
"create DAO failure should map to safe message");
|
||||
|
||||
ServiceResult<Void> unavailableUpdate = failingService.updateReader(librarian,
|
||||
reader(1L, "RD-1007", null, "Unavailable Update", "13800000009", "", ReaderStatus.ACTIVE, 5));
|
||||
require(!unavailableUpdate.isSuccessful(), "update DAO failure should not escape service");
|
||||
require(UNAVAILABLE_MESSAGE.equals(unavailableUpdate.getMessage()),
|
||||
"update DAO failure should map to safe message");
|
||||
|
||||
ServiceResult<Void> unavailableDeactivate = failingService.deactivateReader(librarian, 1L);
|
||||
require(!unavailableDeactivate.isSuccessful(), "deactivate DAO failure should not escape service");
|
||||
require(UNAVAILABLE_MESSAGE.equals(unavailableDeactivate.getMessage()),
|
||||
"deactivate DAO failure should map to safe message");
|
||||
}
|
||||
|
||||
private static AuthenticatedUser user(long id, Role role) {
|
||||
return new AuthenticatedUser(id, role.getCode(), role.getDisplayName(), role,
|
||||
role == Role.READER
|
||||
? EnumSet.of(Permission.VIEW_CATALOG, Permission.BORROW_BOOKS)
|
||||
: EnumSet.of(Permission.MANAGE_BOOKS, Permission.MANAGE_READERS, Permission.VIEW_CATALOG));
|
||||
}
|
||||
|
||||
private static Reader reader(long id, String identifier, Long userId, String fullName, String phone,
|
||||
String email, ReaderStatus status, int maxBorrowCount) {
|
||||
Reader reader = new Reader();
|
||||
reader.setId(id);
|
||||
reader.setIdentifier(identifier);
|
||||
reader.setUserId(userId);
|
||||
reader.setFullName(fullName);
|
||||
reader.setPhone(phone);
|
||||
reader.setEmail(email);
|
||||
reader.setStatus(status);
|
||||
reader.setMaxBorrowCount(maxBorrowCount);
|
||||
return reader;
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemoryReaderDao implements ReaderDao {
|
||||
private final Map<Long, Reader> readers = new LinkedHashMap<>();
|
||||
private long nextId = 1L;
|
||||
|
||||
@Override
|
||||
public List<Reader> search(ReaderSearchCriteria criteria) {
|
||||
List<Reader> matches = new ArrayList<>();
|
||||
for (Reader reader : readers.values()) {
|
||||
if (matches(criteria.getIdentifier(), reader.getIdentifier())
|
||||
&& matches(criteria.getName(), reader.getFullName())
|
||||
&& contactMatches(criteria.getContact(), reader)
|
||||
&& (criteria.getStatusCode() == null || criteria.getStatusCode().isEmpty()
|
||||
|| reader.getStatus().getCode().equals(criteria.getStatusCode()))) {
|
||||
matches.add(copy(reader));
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findById(long id) {
|
||||
return Optional.ofNullable(readers.get(id)).map(this::copy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findByIdentifier(String identifier) {
|
||||
for (Reader reader : readers.values()) {
|
||||
if (reader.getIdentifier().equals(identifier)) {
|
||||
return Optional.of(copy(reader));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findByUserId(long userId) {
|
||||
for (Reader reader : readers.values()) {
|
||||
if (reader.getUserId() != null && reader.getUserId() == userId) {
|
||||
return Optional.of(copy(reader));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long create(Reader reader) {
|
||||
long id = nextId++;
|
||||
Reader stored = copy(reader);
|
||||
stored.setId(id);
|
||||
readers.put(id, stored);
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean update(Reader reader) {
|
||||
if (!readers.containsKey(reader.getId())) {
|
||||
return false;
|
||||
}
|
||||
readers.put(reader.getId(), copy(reader));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deactivate(long id) {
|
||||
Reader reader = readers.get(id);
|
||||
if (reader == null) {
|
||||
return false;
|
||||
}
|
||||
reader.setStatus(ReaderStatus.INACTIVE);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean matches(String filter, String value) {
|
||||
return filter == null || filter.isEmpty() || value.contains(filter);
|
||||
}
|
||||
|
||||
private boolean contactMatches(String filter, Reader reader) {
|
||||
if (filter == null || filter.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
return (reader.getPhone() != null && reader.getPhone().contains(filter))
|
||||
|| (reader.getEmail() != null && reader.getEmail().contains(filter));
|
||||
}
|
||||
|
||||
private Reader copy(Reader source) {
|
||||
Reader copy = reader(source.getId(), source.getIdentifier(), source.getUserId(), source.getFullName(),
|
||||
source.getPhone(), source.getEmail(), source.getStatus(), source.getMaxBorrowCount());
|
||||
copy.setUsername(source.getUsername());
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FailingReaderDao implements ReaderDao {
|
||||
@Override
|
||||
public List<Reader> search(ReaderSearchCriteria criteria) {
|
||||
throw new DaoException("Simulated search failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findById(long id) {
|
||||
throw new DaoException("Simulated find failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findByIdentifier(String identifier) {
|
||||
throw new DaoException("Simulated find failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Reader> findByUserId(long userId) {
|
||||
throw new DaoException("Simulated find failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long create(Reader reader) {
|
||||
throw new DaoException("Simulated create failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean update(Reader reader) {
|
||||
throw new DaoException("Simulated update failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deactivate(long id) {
|
||||
throw new DaoException("Simulated deactivate failure", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user