Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions descriptors/ModuleDescriptor-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,16 @@
"id": "circulation",
"version": "14.7",
"handlers": [
{
"methods": ["POST"],
"pathPattern": "/request-anonymization",
"permissionsRequired": ["circulation.requests.anonymize.execute"],
"modulePermissions": [
"circulation-storage.requests.item.post",
"circulation-storage.requests.item.get",
"circulation-storage.circulation-logs.post"
]
},
{
"methods": [
"POST"
Expand Down Expand Up @@ -1428,6 +1438,12 @@
}
],
"permissionSets": [
{
"permissionName": "circulation.requests.anonymize.execute",
"displayName": "Circulation - Anonymize closed requests",
"description": "Permission to anonymize closed circulation requests",
"visible": true
},
{
"permissionName": "circulation.print-events-entry.item.post",
"displayName": "circulation - create print events",
Expand Down
19 changes: 19 additions & 0 deletions ramls/circulation.raml
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,22 @@ resourceTypes:
body:
text/plain:
example: "Internal server error"
/request-anonymization:
displayName: Request Anonymization
description: Anonymize closed circulation requests
post:
description: Anonymize closed circulation requests
body:
application/json:
type: !include schema/anonymize-circulation-request.json
responses:
200:
body:
application/json:
type: !include schema/anonymization-result.json
422:
description: "Validation errors"
body:
application/json:
type: errors

7 changes: 7 additions & 0 deletions ramls/examples/anonymization-result.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"processed": 2,
"anonymizedRequests": [
"cf23adf0-61ba-4887-bf82-956c4aae2260",
"550e8400-e29b-41d4-a716-446655440000"
]
}
7 changes: 7 additions & 0 deletions ramls/examples/anonymize-circulation-request.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"requestIds": [
"cf23adf0-61ba-4887-bf82-956c4aae2260",
"550e8400-e29b-41d4-a716-446655440000"
],
"includeCirculationLogs": true
}
21 changes: 21 additions & 0 deletions ramls/schema/anonymization-result.json
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you add top level description, please. thank you

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Result of request anonymization operation containing the number of processed requests and list of anonymized request IDs",
"type": "object",
"properties": {
"processed": {
"type": "integer",
"description": "Total number of requests processed in the anonymization operation"
},
"anonymizedRequests": {
"type": "array",
"description": "List of UUIDs for requests that were successfully anonymized",
"items": {
"type": "string",
"format": "uuid",
"description": "UUID of an anonymized request"
}
}
},
"required": ["processed", "anonymizedRequests"]
}
22 changes: 22 additions & 0 deletions ramls/schema/anonymize-circulation-request.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Request to anonymize one or more closed circulation requests by removing personally identifiable information",
"type": "object",
"properties": {
"requestIds": {
"type": "array",
"description": "Array of request UUIDs to be anonymized. All requests must have a closed status.",
"items": {
"type": "string",
"format": "uuid",
"description": "UUID of a request to anonymize"
}
},
"includeCirculationLogs": {
"type": "boolean",
"description": "Whether to anonymize circulation logs associated with these requests. When true, existing circulation log entries will have userBarcode set to '-' and a new anonymization log entry will be created. Defaults to true if not specified.",
"default": true
}
},
"required": ["requestIds"]
}
3 changes: 3 additions & 0 deletions src/main/java/org/folio/circulation/CirculationVerticle.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
import org.folio.circulation.resources.foruseatlocation.PickupByBarcodeResource;
import org.folio.circulation.support.logging.LogHelper;
import org.folio.circulation.support.logging.Logging;
import org.folio.circulation.resources.RequestAnonymizationResource;

import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
Expand Down Expand Up @@ -168,6 +169,8 @@ public void start(Promise<Void> startFuture) {
new CirculationSettingsResource(client).register(router);
new PrintEventsResource(client).register(router);

new RequestAnonymizationResource(client).register(router);

server.requestHandler(router)
.listen(config().getInteger("port"), result -> {
if (result.succeeded()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package org.folio.circulation.resources;

import static org.folio.circulation.support.results.Result.succeeded;

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;

import org.folio.circulation.infrastructure.storage.requests.RequestRepository;
import org.folio.circulation.support.Clients;
import org.folio.circulation.support.RouteRegistration;
import org.folio.circulation.support.http.server.JsonHttpResponse;
import org.folio.circulation.support.http.server.ValidationError;
import org.folio.circulation.support.http.server.WebContext;
import org.folio.circulation.support.ValidationErrorFailure;
import org.folio.circulation.support.results.Result;
import org.folio.circulation.domain.RequestStatus;
import org.folio.circulation.support.logging.Logging;

import io.vertx.core.http.HttpClient;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;

public class RequestAnonymizationResource extends Resource {

private static final java.lang.invoke.MethodHandles.Lookup LOOKUP =
java.lang.invoke.MethodHandles.lookup();
private static final org.apache.logging.log4j.Logger log =
org.apache.logging.log4j.LogManager.getLogger(LOOKUP.lookupClass());
public RequestAnonymizationResource(HttpClient client) {
super(client);
}

@Override
public void register(Router router) {
RouteRegistration routeRegistration = new RouteRegistration(
"/request-anonymization", router);
routeRegistration.create(this::anonymizeRequests);
}

void anonymizeRequests(RoutingContext routingContext) {
final WebContext context = new WebContext(routingContext);
final Clients clients = Clients.create(context, client);

JsonObject body = routingContext.getBodyAsJson();

// Validate request body
if (body == null || !body.containsKey("requestIds")) {
log.warn("anonymizeRequests:: Request body missing requestIds");
Result<JsonObject> failedResult = Result.failed(new ValidationErrorFailure(
new ValidationError("requestIds array is required", "requestIds", null)));
context.writeResultToHttpResponse(failedResult.map(JsonHttpResponse::ok));
return;
}

List<String> requestIds = body.getJsonArray("requestIds")
.stream()
.map(Object::toString)
.collect(Collectors.toList());

if (requestIds.isEmpty()) {
log.warn("anonymizeRequests:: requestIds array is empty");
Result<JsonObject> failedResult = Result.failed(new ValidationErrorFailure(
new ValidationError("requestIds array cannot be empty", "requestIds", null)));
context.writeResultToHttpResponse(failedResult.map(JsonHttpResponse::ok));
return;
}

// Get includeCirculationLogs parameter (default to true)
boolean includeCirculationLogs = body.getBoolean("includeCirculationLogs", true);
log.info("anonymizeRequests:: Processing {} requests, includeCirculationLogs={}",
requestIds.size(), includeCirculationLogs);

// Chain the operations
validateRequestsEligible(requestIds, clients)
.thenCompose(r -> r.after(v ->
anonymizeRequestsInStorage(requestIds, includeCirculationLogs, clients)))
.thenApply(r -> r.map(v -> {
log.info("anonymizeRequests:: Successfully anonymized {} requests", requestIds.size());
return new JsonObject()
.put("processed", requestIds.size())
.put("anonymizedRequests", new JsonArray(requestIds));
}))
.thenApply(r -> r.map(JsonHttpResponse::ok))
.thenAccept(result -> {
if (result.failed()) {
log.error("anonymizeRequests:: Failed to anonymize requests: {}",
result.cause().toString());
}
context.writeResultToHttpResponse(result);
});
}

CompletableFuture<Result<Void>> validateRequestsEligible(
List<String> requestIds, Clients clients) {

RequestRepository requestRepository = new RequestRepository(clients);

List<CompletableFuture<Result<org.folio.circulation.domain.Request>>> futures =
requestIds.stream()
.map(requestRepository::getById)
.collect(Collectors.toList());

return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> {
for (CompletableFuture<Result<org.folio.circulation.domain.Request>> future : futures) {
Result<org.folio.circulation.domain.Request> result = future.join();

if (result.failed()) {
log.warn("validateRequestsEligible:: Failed to retrieve request: {}",
result.cause().toString());
return Result.failed(result.cause());
}

org.folio.circulation.domain.Request request = result.value();
RequestStatus status = request.getStatus();

// Use the RequestStatus enum for cleaner validation
boolean isEligible = status == RequestStatus.CLOSED_FILLED ||
status == RequestStatus.CLOSED_CANCELLED ||
status == RequestStatus.CLOSED_PICKUP_EXPIRED ||
status == RequestStatus.CLOSED_UNFILLED;

if (!isEligible) {
log.warn("validateRequestsEligible:: Request {} has ineligible status: {}",
request.getId(), status.getValue());
return Result.failed(new ValidationErrorFailure(
new ValidationError(
"Request " + request.getId() + " cannot be anonymized - status must be closed",
"status", status.getValue())));
}

// Optional: Check if already anonymized
if (request.getRequesterId() == null || request.getRequesterId().isEmpty()) {
log.warn("validateRequestsEligible:: Request {} appears to be already anonymized",
request.getId());
return Result.failed(new ValidationErrorFailure(
new ValidationError(
"Request " + request.getId() + " appears to be already anonymized",
"requesterId", null)));
}
}

log.info("validateRequestsEligible:: All {} requests are eligible for anonymization",
requestIds.size());
return succeeded(null);
});
}

CompletableFuture<Result<Void>> anonymizeRequestsInStorage(
List<String> requestIds, boolean includeCirculationLogs, Clients clients) {

JsonObject payload = new JsonObject()
.put("requestIds", new JsonArray(requestIds))
.put("includeCirculationLogs", includeCirculationLogs);

log.info("anonymizeRequestsInStorage:: Sending anonymization request to storage layer");

return clients.requestsStorage()
.post(payload, "/request-storage/requests/anonymize")
.thenApply(r -> {
if (r.succeeded()) {
log.info("anonymizeRequestsInStorage:: Storage layer successfully processed requests");
} else {
log.error("anonymizeRequestsInStorage:: Storage layer failed: {}",
r.cause().toString());
}
return r.map(response -> null);
});
}
}
Loading