Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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();
Comment on lines 36 to 45
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,112 @@ 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 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
24 changes: 24 additions & 0 deletions frontend/src/i18n/workspaceCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,18 @@ type WorkspaceCopy = {
timelineConverted: string
loadMore: string
loadingMore: string
recycleBinTitle: string
deletedCapturesTitle: string
deletedItemsTitle: string
emptyDeletedCaptures: string
emptyDeletedItems: string
restoreLabel: string
restoringLabel: string
failedToLoad: string
failedToCreate: string
failedToConvert: string
failedToUndo: string
failedToRestore: string
failedToUndoMethodNotAllowed: string
confirmConvert: string
confirmDeleteCapture: string
Expand Down Expand Up @@ -118,10 +126,18 @@ export const workspaceCopy: Record<Locale, WorkspaceCopy> = {
timelineConverted: 'Converted to item',
loadMore: 'Load More',
loadingMore: 'Loading...',
recycleBinTitle: 'Recycle Bin',
deletedCapturesTitle: 'Deleted Captures',
deletedItemsTitle: 'Deleted Items',
emptyDeletedCaptures: 'No deleted captures.',
emptyDeletedItems: 'No deleted items.',
restoreLabel: 'Restore',
restoringLabel: 'Restoring...',
failedToLoad: 'Failed to load captures. Please try again.',
failedToCreate: 'Failed to create capture. Please try again.',
failedToConvert: 'Failed to convert capture. Please try again.',
failedToUndo: 'Failed to undo conversion. Please try again.',
failedToRestore: 'Failed to restore item. Please try again.',
Comment thread
hammond01 marked this conversation as resolved.
Outdated
failedToUndoMethodNotAllowed:
'Undo is not available on the current backend. Please update/restart backend to a version that supports POST /api/v1/captures/{captureId}/undo.',
confirmConvert: 'Convert this capture into an item?',
Expand Down Expand Up @@ -189,10 +205,18 @@ export const workspaceCopy: Record<Locale, WorkspaceCopy> = {
timelineConverted: 'Đã chuyển thành item',
loadMore: 'Tải thêm',
loadingMore: 'Đang tải...',
recycleBinTitle: 'Thùng rác',
deletedCapturesTitle: 'Capture đã xóa',
deletedItemsTitle: 'Item đã xóa',
emptyDeletedCaptures: 'Không có capture đã xóa.',
emptyDeletedItems: 'Không có item đã xóa.',
restoreLabel: 'Khôi phục',
restoringLabel: 'Đang khôi phục...',
failedToLoad: 'Không thể tải danh sách capture. Vui lòng thử lại.',
failedToCreate: 'Không thể tạo capture. Vui lòng thử lại.',
failedToConvert: 'Không thể chuyển capture. Vui lòng thử lại.',
failedToUndo: 'Không thể hoàn tác chuyển đổi. Vui lòng thử lại.',
failedToRestore: 'Không thể khôi phục. Vui lòng thử lại.',
failedToUndoMethodNotAllowed:
'Backend hiện tại chưa hỗ trợ Undo. Vui lòng cập nhật/khởi động lại backend có endpoint POST /api/v1/captures/{captureId}/undo.',
confirmConvert: 'Bạn có chắc muốn chuyển capture này thành một item?',
Expand Down
Loading
Loading