Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ public ResponseEntity<Capture> createCapture(@Valid @RequestBody CreateCaptureRe
public ResponseEntity<List<Capture>> getCaptures(
@RequestParam(required = false) Integer limit,
@RequestParam(required = false) String cursor,
@RequestParam(required = false) Boolean converted
@RequestParam(required = false) Boolean converted,
@RequestParam(required = false) Boolean deleted
) {
CursorPage<Capture> page = captureService.getCaptures(limit, cursor, converted);
CursorPage<Capture> page = captureService.getCaptures(limit, cursor, converted, deleted);
ResponseEntity.BodyBuilder response = ResponseEntity.ok();
if (page.nextCursor() != null) {
response.header("X-Next-Cursor", page.nextCursor());
Expand Down Expand Up @@ -85,4 +86,10 @@ public ResponseEntity<Void> undoConversion(@PathVariable String captureId) {
captureService.undoConversion(captureId);
return ResponseEntity.noContent().build();
}

@PostMapping("/{captureId}/restore")
public ResponseEntity<Void> restoreCapture(@PathVariable String captureId) {
captureService.restoreCapture(captureId);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
Expand All @@ -37,9 +38,10 @@ public ResponseEntity<List<Item>> getItems(
@RequestParam(required = false) Integer limit,
@RequestParam(required = false) String cursor,
@RequestParam(required = false) ItemStatus status,
@RequestParam(required = false) ItemType type
@RequestParam(required = false) ItemType type,
@RequestParam(required = false) Boolean deleted
Comment on lines 38 to +42
) {
CursorPage<Item> page = itemService.getItems(limit, cursor, status, type);
CursorPage<Item> page = itemService.getItems(limit, cursor, status, type, deleted);
ResponseEntity.BodyBuilder response = ResponseEntity.ok();
if (page.nextCursor() != null) {
response.header("X-Next-Cursor", page.nextCursor());
Expand Down Expand Up @@ -74,4 +76,10 @@ public ResponseEntity<Void> deleteItem(@PathVariable String itemId) {
itemService.deleteItem(itemId);
return ResponseEntity.noContent().build();
}

@PostMapping("/{itemId}/restore")
public ResponseEntity<Void> restoreItem(@PathVariable String itemId) {
itemService.restoreItem(itemId);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ public enum ActivityAction {
CAPTURE_UPDATED,
CAPTURE_CONVERTED,
CAPTURE_DELETED,
CAPTURE_RESTORED,
ITEM_UPDATED,
ITEM_STATUS_UPDATED,
ITEM_DELETED,
ITEM_RESTORED,
CONVERSION_UNDONE
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ public Capture createCapture(String rawContent) {
return saved;
}

public CursorPage<Capture> getCaptures(Integer limit, String cursor, Boolean converted) {
public CursorPage<Capture> getCaptures(Integer limit, String cursor, Boolean converted, Boolean deleted) {
List<Capture> filtered = captureRepository.findAllNewestFirst().stream()
.filter(capture -> capture.deletedAt() == null)
.filter(capture -> deleted == null
? capture.deletedAt() == null
: deleted.equals(capture.deletedAt() != null))
.filter(capture -> converted == null
|| (converted ? capture.convertedItemId() != null : capture.convertedItemId() == null))
.sorted(Comparator.comparing(Capture::createdAt).reversed()
Expand Down Expand Up @@ -263,6 +265,55 @@ public void undoConversion(String captureId) {
);
}

@Transactional
public void restoreCapture(String captureId) {
Capture capture = findCaptureOrThrow(captureId);
if (capture.deletedAt() == null) {
throw new ConflictException("Capture is not deleted");
}

String now = Instant.now().toString();
captureRepository.save(new Capture(
capture.rowId(),
capture.id(),
capture.content(),
capture.createdAt(),
capture.createdBy(),
now,
AuditContext.SYSTEM_ACTOR,
capture.convertedAt(),
capture.convertedItemId(),
null
));

if (capture.convertedItemId() != null) {
itemRepository.findById(capture.convertedItemId()).ifPresent(item -> {
if (item.deletedAt() != null) {
itemRepository.save(new Item(
item.rowId(),
item.id(),
item.type(),
item.status(),
item.content(),
item.createdAt(),
item.createdBy(),
now,
AuditContext.SYSTEM_ACTOR,
item.sourceCaptureId(),
null
));
}
});
}

activityLogService.log(
ActivityAction.CAPTURE_RESTORED,
capture.id(),
capture.convertedItemId(),
null
);
}

private String normalizeContent(String rawContent) {
if (rawContent == null) {
throw new BadRequestException("Capture content is required");
Expand Down
54 changes: 52 additions & 2 deletions backend/src/main/java/io/opspace/backend/service/ItemService.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.opspace.backend.repository.CaptureRepository;
import io.opspace.backend.repository.ItemRepository;
import io.opspace.backend.shared.BadRequestException;
import io.opspace.backend.shared.ConflictException;
import io.opspace.backend.shared.CursorPage;
import io.opspace.backend.shared.CursorPagination;
import io.opspace.backend.shared.NotFoundException;
Expand Down Expand Up @@ -39,9 +40,11 @@ public ItemService(
/**
* Returns items sorted by newest first to match current UI expectation.
*/
public CursorPage<Item> getItems(Integer limit, String cursor, ItemStatus status, ItemType type) {
public CursorPage<Item> getItems(Integer limit, String cursor, ItemStatus status, ItemType type, Boolean deleted) {
List<Item> filtered = itemRepository.findAllNewestFirst().stream()
.filter(item -> item.deletedAt() == null)
.filter(item -> deleted == null
? item.deletedAt() == null
: deleted.equals(item.deletedAt() != null))
.filter(item -> status == null || item.status() == status)
.filter(item -> type == null || item.type() == type)
.sorted(Comparator.comparing(Item::createdAt).reversed()
Expand Down Expand Up @@ -175,6 +178,53 @@ public Item updateItem(String itemId, ItemStatus status, String rawContent) {
return saved;
}

@Transactional
public void restoreItem(String itemId) {
Item item = findItemOrThrow(itemId);
if (item.deletedAt() == null) {
throw new ConflictException("Item is not deleted");
}

String now = Instant.now().toString();
itemRepository.save(new Item(
item.rowId(),
item.id(),
item.type(),
item.status(),
item.content(),
item.createdAt(),
item.createdBy(),
now,
AuditContext.SYSTEM_ACTOR,
item.sourceCaptureId(),
null
));

captureRepository.findById(item.sourceCaptureId()).ifPresent(capture -> {
if (capture.deletedAt() != null) {
captureRepository.save(new Capture(
capture.rowId(),
capture.id(),
capture.content(),
capture.createdAt(),
capture.createdBy(),
now,
AuditContext.SYSTEM_ACTOR,
capture.convertedAt(),
capture.convertedItemId(),
null
));
}
});

activityLogService.log(
ActivityAction.ITEM_RESTORED,
item.sourceCaptureId(),
item.id(),
null
);
}

private Item findItemOrThrow(String itemId) {
return itemRepository.findById(itemId)
.orElseThrow(() -> new NotFoundException("Item not found"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,167 @@ void deleteCaptureSoftDeletesLinkedItem() throws Exception {
.andExpect(jsonPath("$.error").value("Item not found"));
}

@Test
void getCapturesSupportsDeletedFilter() throws Exception {
String deletedCaptureId = createCaptureAndReturnId("Deleted capture");
mockMvc.perform(delete("/api/v1/captures/{captureId}", deletedCaptureId))
.andExpect(status().isNoContent());

String activeCaptureId = createCaptureAndReturnId("Active capture");

MvcResult deletedResult = mockMvc.perform(get("/api/v1/captures")
.param("deleted", "true"))
.andExpect(status().isOk())
.andReturn();
JsonNode deletedCaptures = objectMapper.readTree(deletedResult.getResponse().getContentAsString());
assertEquals(1, deletedCaptures.size());
assertEquals(deletedCaptureId, deletedCaptures.get(0).get("id").asText());

MvcResult activeResult = mockMvc.perform(get("/api/v1/captures")
.param("deleted", "false"))
.andExpect(status().isOk())
.andReturn();
JsonNode activeCaptures = objectMapper.readTree(activeResult.getResponse().getContentAsString());
assertEquals(1, activeCaptures.size());
assertEquals(activeCaptureId, activeCaptures.get(0).get("id").asText());
}

@Test
Comment thread
hammond01 marked this conversation as resolved.
void getItemsSupportsDeletedFilter() throws Exception {
String deletedItemCaptureId = createCaptureAndReturnId("Deleted item capture");
MvcResult deletedItemConversionResult = mockMvc.perform(post("/api/v1/captures/{captureId}/convert",
deletedItemCaptureId)
.contentType(APPLICATION_JSON)
.content("""
{"type":"TASK"}
"""))
.andExpect(status().isCreated())
.andReturn();
String deletedItemId = objectMapper.readTree(deletedItemConversionResult.getResponse().getContentAsString())
.get("id")
.asText();

mockMvc.perform(delete("/api/v1/captures/{captureId}", deletedItemCaptureId))
.andExpect(status().isNoContent());

String activeItemCaptureId = createCaptureAndReturnId("Active item capture");
MvcResult activeItemConversionResult = mockMvc.perform(post("/api/v1/captures/{captureId}/convert",
activeItemCaptureId)
.contentType(APPLICATION_JSON)
.content("""
{"type":"TASK"}
"""))
.andExpect(status().isCreated())
.andReturn();
String activeItemId = objectMapper.readTree(activeItemConversionResult.getResponse().getContentAsString())
.get("id")
.asText();

MvcResult deletedResult = mockMvc.perform(get("/api/v1/items")
.param("deleted", "true"))
.andExpect(status().isOk())
.andReturn();
JsonNode deletedItems = objectMapper.readTree(deletedResult.getResponse().getContentAsString());
assertEquals(1, deletedItems.size());
assertEquals(deletedItemId, deletedItems.get(0).get("id").asText());

MvcResult activeResult = mockMvc.perform(get("/api/v1/items")
.param("deleted", "false"))
.andExpect(status().isOk())
.andReturn();
JsonNode activeItems = objectMapper.readTree(activeResult.getResponse().getContentAsString());
assertEquals(1, activeItems.size());
assertEquals(activeItemId, activeItems.get(0).get("id").asText());

MvcResult defaultResult = mockMvc.perform(get("/api/v1/items"))
.andExpect(status().isOk())
.andReturn();
JsonNode defaultItems = objectMapper.readTree(defaultResult.getResponse().getContentAsString());
assertEquals(1, defaultItems.size());
assertEquals(activeItemId, defaultItems.get(0).get("id").asText());
}

@Test
void restoreCaptureBringsBackCaptureAndLinkedItem() throws Exception {
String captureId = createCaptureAndReturnId("Restore capture");
MvcResult conversionResult = mockMvc.perform(post("/api/v1/captures/{captureId}/convert", captureId)
.contentType(APPLICATION_JSON)
.content("""
{"type":"TASK"}
"""))
.andExpect(status().isCreated())
.andReturn();
String itemId = objectMapper.readTree(conversionResult.getResponse().getContentAsString())
.get("id")
.asText();

mockMvc.perform(delete("/api/v1/captures/{captureId}", captureId))
.andExpect(status().isNoContent());

mockMvc.perform(post("/api/v1/captures/{captureId}/restore", captureId))
.andExpect(status().isNoContent());

mockMvc.perform(get("/api/v1/captures/{captureId}", captureId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(captureId));

mockMvc.perform(get("/api/v1/items/{itemId}", itemId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(itemId));
}

@Test
void restoreItemBringsBackItemAndSourceCapture() throws Exception {
String captureId = createCaptureAndReturnId("Restore item source");
MvcResult conversionResult = mockMvc.perform(post("/api/v1/captures/{captureId}/convert", captureId)
.contentType(APPLICATION_JSON)
.content("""
{"type":"NOTE"}
"""))
.andExpect(status().isCreated())
.andReturn();
String itemId = objectMapper.readTree(conversionResult.getResponse().getContentAsString())
.get("id")
.asText();

mockMvc.perform(delete("/api/v1/items/{itemId}", itemId))
.andExpect(status().isNoContent());

mockMvc.perform(post("/api/v1/items/{itemId}/restore", itemId))
.andExpect(status().isNoContent());

mockMvc.perform(get("/api/v1/items/{itemId}", itemId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(itemId));

mockMvc.perform(get("/api/v1/captures/{captureId}", captureId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(captureId));
}

@Test
void restoreEndpointsReturnConflictWhenEntityNotDeleted() throws Exception {
String captureId = createCaptureAndReturnId("Active capture");
MvcResult conversionResult = mockMvc.perform(post("/api/v1/captures/{captureId}/convert", captureId)
.contentType(APPLICATION_JSON)
.content("""
{"type":"TASK"}
"""))
.andExpect(status().isCreated())
.andReturn();
String itemId = objectMapper.readTree(conversionResult.getResponse().getContentAsString())
.get("id")
.asText();

mockMvc.perform(post("/api/v1/captures/{captureId}/restore", captureId))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error").value("Capture is not deleted"));

mockMvc.perform(post("/api/v1/items/{itemId}/restore", itemId))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error").value("Item is not deleted"));
}

@Test
void updateCaptureSyncsConvertedItemContent() throws Exception {
String captureId = createCaptureAndReturnId("Old capture");
Expand Down
Loading
Loading