feat(checklist): trash mode toggle to view deleted items

This commit is contained in:
2026-05-15 01:00:22 +03:00
parent d5765be160
commit ec9c1767bb
8 changed files with 813 additions and 11 deletions

View File

@@ -200,6 +200,34 @@ final class ChecklistController extends OCSController {
});
}
/**
* List soft-deleted items in a checklist (trash)
*
* Returns items whose deleted_at is set, most recently deleted first.
*
* @param int $houseId House id.
* @param int $listId List id.
* @param int<1, 1000> $limit Maximum number of items to return.
* @param int<0, max> $offset Number of items to skip.
*
* @return DataResponse<Http::STATUS_OK, list<PantryListItem>, array{}>
*
* 200: Deleted items returned
*/
#[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/lists/{listId}/items/trash')]
#[NoAdminRequired]
public function indexDeletedItems(int $houseId, int $listId, int $limit = 200, int $offset = 0): DataResponse {
return $this->runAction(function () use ($houseId, $listId, $limit, $offset): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$list = $this->lists->getList($listId);
$this->assertListInHouse($list->getHouseId(), $houseId);
$all = $this->lists->listDeletedItems($listId);
$sliced = array_slice($all, max(0, $offset), max(0, $limit));
$items = array_map(fn ($i) => $i->jsonSerialize(), $sliced);
return new DataResponse($items);
});
}
/**
* Add an item to a list
*
@@ -401,6 +429,84 @@ final class ChecklistController extends OCSController {
});
}
/**
* Restore a soft-deleted item back into the active list
*
* @param int $houseId House id.
* @param int $listId List id.
* @param int $itemId Item id.
*
* @return DataResponse<Http::STATUS_OK, PantryListItem, array{}>
*
* 200: Item restored
*/
#[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/lists/{listId}/items/{itemId}/restore')]
#[NoAdminRequired]
public function restoreItem(int $houseId, int $listId, int $itemId): DataResponse {
return $this->runAction(function () use ($houseId, $listId, $itemId): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$item = $this->lists->getItem($itemId, includeDeleted: true);
$list = $this->lists->getList($item->getListId());
$this->assertListInHouse($list->getHouseId(), $houseId);
if ($item->getListId() !== $listId) {
throw new NotFoundException('Item does not belong to this list');
}
$restored = $this->lists->restoreItem($itemId);
return new DataResponse($restored->jsonSerialize());
});
}
/**
* Permanently delete an item, bypassing the trash
*
* Works on both live items and items already in trash.
*
* @param int $houseId House id.
* @param int $listId List id.
* @param int $itemId Item id.
*
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
*
* 200: Item permanently deleted
*/
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/lists/{listId}/items/{itemId}/permanent')]
#[NoAdminRequired]
public function permanentlyDeleteItem(int $houseId, int $listId, int $itemId): DataResponse {
return $this->runAction(function () use ($houseId, $listId, $itemId): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$item = $this->lists->getItem($itemId, includeDeleted: true);
$list = $this->lists->getList($item->getListId());
$this->assertListInHouse($list->getHouseId(), $houseId);
if ($item->getListId() !== $listId) {
throw new NotFoundException('Item does not belong to this list');
}
$this->lists->permanentlyDeleteItem($itemId);
return new DataResponse(['success' => true]);
});
}
/**
* Empty a list's trash, permanently deleting every soft-deleted item
*
* @param int $houseId House id.
* @param int $listId List id.
*
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
*
* 200: Trash emptied
*/
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/lists/{listId}/items/trash')]
#[NoAdminRequired]
public function emptyTrash(int $houseId, int $listId): DataResponse {
return $this->runAction(function () use ($houseId, $listId): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$list = $this->lists->getList($listId);
$this->assertListInHouse($list->getHouseId(), $houseId);
$this->lists->emptyTrash($listId);
return new DataResponse(['success' => true]);
});
}
/**
* Batch reorder items in a list
*

View File

@@ -135,10 +135,37 @@ class ChecklistItemMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Find soft-deleted items in a list, most recently deleted first.
*
* @return ChecklistItem[]
*/
public function findDeletedByList(int $listId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('list_id', $qb->createNamedParameter($listId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->isNotNull('deleted_at'))
->orderBy('deleted_at', 'DESC');
return $this->findEntities($qb);
}
public function deleteByList(int $listId): void {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->getTableName())
->where($qb->expr()->eq('list_id', $qb->createNamedParameter($listId, IQueryBuilder::PARAM_INT)));
$qb->executeStatement();
}
/**
* Hard-delete every soft-deleted item in the list.
*/
public function emptyTrashForList(int $listId): void {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->getTableName())
->where($qb->expr()->eq('list_id', $qb->createNamedParameter($listId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->isNotNull('deleted_at'));
$qb->executeStatement();
}
}

View File

@@ -106,9 +106,18 @@ class ChecklistService {
return $this->itemMapper->findByList($listId, $sortBy);
}
public function getItem(int $itemId): ChecklistItem {
/**
* List soft-deleted items for a list. Most recently deleted first.
*
* @return ChecklistItem[]
*/
public function listDeletedItems(int $listId): array {
return $this->itemMapper->findDeletedByList($listId);
}
public function getItem(int $itemId, bool $includeDeleted = false): ChecklistItem {
try {
return $this->itemMapper->findById($itemId);
return $this->itemMapper->findById($itemId, $includeDeleted);
} catch (DoesNotExistException) {
throw new NotFoundException('Item not found');
}
@@ -360,6 +369,33 @@ class ChecklistService {
$this->itemMapper->update($item);
}
/**
* Permanently remove an item, regardless of whether it is currently in
* trash. Bypasses the soft-delete row and erases it from the table.
*/
public function permanentlyDeleteItem(int $itemId): void {
$item = $this->getItem($itemId, includeDeleted: true);
$this->itemMapper->delete($item);
}
/**
* Restore a soft-deleted item by clearing its deleted_at marker.
*/
public function restoreItem(int $itemId): ChecklistItem {
$item = $this->getItem($itemId, includeDeleted: true);
$item->setDeletedAt(null);
$item->setUpdatedAt(time());
$this->itemMapper->update($item);
return $item;
}
/**
* Hard-delete every soft-deleted item in the list.
*/
public function emptyTrash(int $listId): void {
$this->itemMapper->emptyTrashForList($listId);
}
private function strOrNull(mixed $v): ?string {
if (!is_string($v)) {
return null;