diff --git a/NEWS.md b/NEWS.md index 82b544f5e..df69868c4 100644 --- a/NEWS.md +++ b/NEWS.md @@ -10,6 +10,11 @@ * [MODSOURCE-860](https://folio-org.atlassian.net/browse/MODSOURCE-860) "Numerics only" option of existing record section does not work during MARC-BIB to MARC-BIB matching * [MODINV-1114](https://folio-org.atlassian.net/browse/MODINV-1114) Extend matching records endpoint to support multiple marc-bib match results processing * [MODSOURCE-863](https://folio-org.atlassian.net/browse/MODSOURCE-863) Add index to speed up the querying of a composite record +* [MODSOURCE-859](https://folio-org.atlassian.net/browse/MODSOURCE-859) Added record undelete endpoint + +| METHOD | URL | DESCRIPTION | +|--------|------------------------------------------|-----------------------------------------------------| +| POST | /source-storage/records/{id}/un-delete | Undelete the record by setting the state to ACTUAL. | ## 2024-10-28 5.9.0 * [MODSOURCE-767](https://folio-org.atlassian.net/browse/MODSOURCE-767) Single record overlay creates duplicate OCLC#/035 diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index eeb94569f..14e3d275b 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -101,6 +101,15 @@ "source-storage.records.generation.item.put" ] }, + { + "methods": [ + "POST" + ], + "pathPattern": "/source-storage/records/{id}/un-delete", + "permissionsRequired": [ + "source-storage.records.undelete.item.post" + ] + }, { "methods": [ "DELETE" @@ -356,6 +365,11 @@ "displayName": "Source Storage - update record's generation", "description": "Update record's generation" }, + { + "permissionName": "source-storage.records.undelete.item.post", + "displayName": "Source Storage - undelete record", + "description": "Undelete record" + }, { "permissionName": "source-storage.parsed-records.collection.put", "displayName": "Source Storage - update records", @@ -474,6 +488,7 @@ "source-storage.migrations.post", "source-storage.migrations.item.get", "source-storage.records.generation.item.put", + "source-storage.records.undelete.item.post", "source-storage.parsed-records.collection.put", "source-storage.batch-records.collection.post", "source-storage.batch.records.collection.post", diff --git a/mod-source-record-storage-server/src/main/java/org/folio/rest/impl/SourceStorageRecordsImpl.java b/mod-source-record-storage-server/src/main/java/org/folio/rest/impl/SourceStorageRecordsImpl.java index 15991393f..a5078d7b7 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/rest/impl/SourceStorageRecordsImpl.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/rest/impl/SourceStorageRecordsImpl.java @@ -193,4 +193,18 @@ public void postSourceStorageRecordsMatching(RecordMatchingDto recordMatchingDto }); } + @Override + public void postSourceStorageRecordsUnDeleteById(String id, String idType, Map okapiHeaders, + Handler> asyncResultHandler, Context vertxContext) { + vertxContext.runOnContext(v -> { + try { + recordService.unDeleteRecordById(id, toExternalIdType(idType), okapiHeaders).map(r -> true) + .map(updated -> PostSourceStorageRecordsUnDeleteByIdResponse.respond204()).map(Response.class::cast) + .otherwise(ExceptionHelper::mapExceptionToResponse).onComplete(asyncResultHandler); + } catch (Exception e) { + LOG.warn("postSourceStorageRecordsUnDeleteById:: Failed to undelete record by id {}", id, e); + asyncResultHandler.handle(Future.succeededFuture(ExceptionHelper.mapExceptionToResponse(e))); + } + }); + } } diff --git a/mod-source-record-storage-server/src/main/java/org/folio/services/RecordService.java b/mod-source-record-storage-server/src/main/java/org/folio/services/RecordService.java index c269d1e48..290d1eee8 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/services/RecordService.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/services/RecordService.java @@ -283,4 +283,6 @@ Future saveRecordsByExternalIds(List externalIds, Future updateRecordsState(String matchedId, RecordState state, RecordType recordType, String tenantId); Future deleteRecordById(String id, IdType idType, Map okapiHeaders); + + Future unDeleteRecordById(String id, IdType idType, Map okapiHeaders); } diff --git a/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java b/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java index 5750ce452..24075476b 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java @@ -97,6 +97,7 @@ public class RecordServiceImpl implements RecordService { private static final String RECORD_WITH_GIVEN_MATCHED_ID_NOT_FOUND = "Record with given matched id (%s) not found"; private static final String NOT_FOUND_MESSAGE = "%s with id '%s' was not found"; private static final Character DELETED_LEADER_RECORD_STATUS = 'd'; + private static final Character CORRECTED_LEADER_RECORD_STATUS = 'c'; public static final String UPDATE_RECORD_DUPLICATE_EXCEPTION = "Incoming record could be a duplicate, incoming record generation should not be the same as matched record generation and the execution of job should be started after of creating the previous record generation"; public static final String EXTERNAL_IDS_MISSING_ERROR = "MARC_BIB records must contain external instance and hr id's and 001 field into parsed record"; protected static final String UPDATE_RECORD_WITH_LINKED_DATA_ID_EXCEPTION = "Record with source=LINKED_DATA cannot be updated using QuickMARC. Please use Linked Data Editor."; @@ -421,6 +422,21 @@ public Future deleteRecordById(String id, IdType idType, Map updateRecord(record, okapiHeaders)).map(r -> null); } + @Override + public Future unDeleteRecordById(String id, IdType idType, Map okapiHeaders) { + var tenantId = okapiHeaders.get(OKAPI_TENANT_HEADER); + return recordDao.getRecordByExternalId(id, idType, tenantId) + .map(recordOptional -> recordOptional.orElseThrow(() -> new NotFoundException(format(NOT_FOUND_MESSAGE, Record.class.getSimpleName(), id)))) + .map(record -> { + update005field(record); + record.withState(Record.State.ACTUAL); + record.setAdditionalInfo(record.getAdditionalInfo().withDeleted(false)); + ParsedRecordDaoUtil.updateLeaderStatus(record.getParsedRecord(), CORRECTED_LEADER_RECORD_STATUS); + return record; + }) + .compose(record -> updateRecord(record, okapiHeaders)).map(r -> null); + } + private Future setMatchedIdForRecord(Record record, String tenantId) { String marcField999s = getFieldFromMarcRecord(record, TAG_999, INDICATOR, INDICATOR, SUBFIELD_S); if (marcField999s != null) { diff --git a/mod-source-record-storage-server/src/test/java/org/folio/services/RecordServiceTest.java b/mod-source-record-storage-server/src/test/java/org/folio/services/RecordServiceTest.java index acd6e3fb7..a1881fd46 100644 --- a/mod-source-record-storage-server/src/test/java/org/folio/services/RecordServiceTest.java +++ b/mod-source-record-storage-server/src/test/java/org/folio/services/RecordServiceTest.java @@ -1714,6 +1714,49 @@ public void shouldHardDeleteMarcRecord(TestContext context) { }); } + @Test + public void shouldUnDeleteMarcRecord(TestContext context) { + Async async = context.async(); + var marcBibMock = TestMocks.getMarcBibRecord(); + var sourceRecord = new Record() + .withId(UUID.randomUUID().toString()) + .withSnapshotId(marcBibMock.getSnapshotId()) + .withRecordType(marcBibMock.getRecordType()) + .withState(State.DELETED) + .withOrder(marcBibMock.getOrder()) + .withRawRecord(rawRecord) + .withParsedRecord(marcRecord) + .withAdditionalInfo(marcBibMock.getAdditionalInfo()) + .withExternalIdsHolder( + new ExternalIdsHolder() + .withInstanceId(UUID.randomUUID().toString()) + .withInstanceHrid(RandomStringUtils.randomAlphanumeric(9))) + .withMetadata(marcBibMock.getMetadata()); + + var okapiHeaders = Map.of(OKAPI_TENANT_HEADER, TENANT_ID); + + recordService.saveRecord(sourceRecord, okapiHeaders).onComplete(saveResult -> { + if (saveResult.failed()) { + context.fail(saveResult.cause()); + } + recordService.unDeleteRecordById(sourceRecord.getId(), IdType.RECORD, okapiHeaders).onComplete(undeleteResult -> { + if (undeleteResult.failed()) { + context.fail(undeleteResult.cause()); + } + recordService.getRecordById(sourceRecord.getId(), TENANT_ID).onComplete(getResult -> { + if (getResult.failed()) { + context.fail(getResult.cause()); + } + context.assertTrue(getResult.result().isPresent()); + context.assertFalse(getResult.result().get().getDeleted()); + verify(recordDomainEventPublisher, times(1)) + .publishRecordUpdated(eq(saveResult.result()), eq(getResult.result().get()), any()); + }); + async.complete(); + }); + }); + } + @Test public void shouldSoftDeleteMarcRecord(TestContext context) { Async async = context.async(); diff --git a/ramls/source-record-storage-records.raml b/ramls/source-record-storage-records.raml index 1d7a99083..c89d93928 100644 --- a/ramls/source-record-storage-records.raml +++ b/ramls/source-record-storage-records.raml @@ -197,3 +197,15 @@ resourceTypes: body: application/json: type: record + /un-delete: + displayName: Undelete + post: + description: Undelete specific record + queryParameters: + idType: + description: Type of Id for Record lookup + type: string + example: INSTANCE + default: RECORD + responses: + 204: