diff --git a/lib/Controller/ChecklistController.php b/lib/Controller/ChecklistController.php index c2453b5..7982aad 100644 --- a/lib/Controller/ChecklistController.php +++ b/lib/Controller/ChecklistController.php @@ -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, 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 + * + * 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 + * + * 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 + * + * 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 * diff --git a/lib/Db/ChecklistItemMapper.php b/lib/Db/ChecklistItemMapper.php index 0067a31..27921cc 100644 --- a/lib/Db/ChecklistItemMapper.php +++ b/lib/Db/ChecklistItemMapper.php @@ -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(); + } } diff --git a/lib/Service/ChecklistService.php b/lib/Service/ChecklistService.php index 27d863b..0407105 100644 --- a/lib/Service/ChecklistService.php +++ b/lib/Service/ChecklistService.php @@ -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; diff --git a/openapi.json b/openapi.json index 2c5a062..0683f0c 100644 --- a/openapi.json +++ b/openapi.json @@ -336,7 +336,8 @@ "imageUploadedBy", "sortOrder", "createdAt", - "updatedAt" + "updatedAt", + "deletedAt" ], "properties": { "id": { @@ -410,6 +411,11 @@ "updatedAt": { "type": "integer", "format": "int64" + }, + "deletedAt": { + "type": "integer", + "format": "int64", + "nullable": true } } }, @@ -2100,6 +2106,249 @@ } } }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}/items/trash": { + "get": { + "operationId": "checklist-index-deleted-items", + "summary": "List soft-deleted items in a checklist (trash)", + "description": "Returns items whose deleted_at is set, most recently deleted first.", + "tags": [ + "checklist" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of items to return.", + "schema": { + "type": "integer", + "format": "int64", + "default": 200, + "minimum": 1, + "maximum": 1000 + } + }, + { + "name": "offset", + "in": "query", + "description": "Number of items to skip.", + "schema": { + "type": "integer", + "format": "int64", + "default": 0, + "minimum": 0 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Deleted items returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ListItem" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "checklist-empty-trash", + "summary": "Empty a list's trash, permanently deleting every soft-deleted item", + "tags": [ + "checklist" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Trash emptied", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Success" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}/items/{itemId}": { "patch": { "operationId": "checklist-update-item", @@ -2531,6 +2780,245 @@ } } }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}/items/{itemId}/restore": { + "post": { + "operationId": "checklist-restore-item", + "summary": "Restore a soft-deleted item back into the active list", + "tags": [ + "checklist" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Item restored", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ListItem" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}/items/{itemId}/permanent": { + "delete": { + "operationId": "checklist-permanently-delete-item", + "summary": "Permanently delete an item, bypassing the trash", + "description": "Works on both live items and items already in trash.", + "tags": [ + "checklist" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Item permanently deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Success" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}/items/reorder": { "post": { "operationId": "checklist-reorder-items", diff --git a/src/api/lists.ts b/src/api/lists.ts index 028747e..6c64ec1 100644 --- a/src/api/lists.ts +++ b/src/api/lists.ts @@ -49,6 +49,11 @@ export async function listItems( return resp.data ?? [] } +export async function listDeletedItems(houseId: number, listId: number): Promise { + const resp = await ocs.get(`/houses/${houseId}/lists/${listId}/items/trash`) + return resp.data ?? [] +} + export interface ItemInput { name: string description?: string | null @@ -98,6 +103,29 @@ export async function deleteItem(houseId: number, listId: number, itemId: number await ocs.delete(`/houses/${houseId}/lists/${listId}/items/${itemId}`) } +export async function permanentlyDeleteItem( + houseId: number, + listId: number, + itemId: number, +): Promise { + await ocs.delete(`/houses/${houseId}/lists/${listId}/items/${itemId}/permanent`) +} + +export async function restoreItem( + houseId: number, + listId: number, + itemId: number, +): Promise { + const resp = await ocs.post( + `/houses/${houseId}/lists/${listId}/items/${itemId}/restore`, + ) + return resp.data +} + +export async function emptyTrash(houseId: number, listId: number): Promise { + await ocs.delete(`/houses/${houseId}/lists/${listId}/items/trash`) +} + export async function reorderItems( houseId: number, listId: number, diff --git a/src/components/ChecklistItemRow/ChecklistItemRow.vue b/src/components/ChecklistItemRow/ChecklistItemRow.vue index 306dbbd..071cd7e 100644 --- a/src/components/ChecklistItemRow/ChecklistItemRow.vue +++ b/src/components/ChecklistItemRow/ChecklistItemRow.vue @@ -52,11 +52,17 @@ {{ strings.moveItem }} + + + {{ strings.restoreItem }} + - {{ strings.removeItem }} + {{ trashMode ? strings.deletePermanently : strings.removeItem }} @@ -74,6 +80,7 @@ import RepeatIcon from '@icons/Repeat.vue' import PencilIcon from '@icons/Pencil.vue' import EyeIcon from '@icons/Eye.vue' import DeleteIcon from '@icons/Delete.vue' +import DeleteRestoreIcon from '@icons/DeleteRestore.vue' import ArrowRightIcon from '@icons/ArrowRight.vue' import { categoryIconComponent } from '@/components/CategoryPicker' import { itemImagePreviewUrl } from '@/api/images' @@ -86,8 +93,9 @@ const props = withDefaults( category: Category | null houseId: number reorderEnabled?: boolean + trashMode?: boolean }>(), - { reorderEnabled: false }, + { reorderEnabled: false, trashMode: false }, ) const emit = defineEmits<{ @@ -96,6 +104,7 @@ const emit = defineEmits<{ edit: [item: ChecklistItem] move: [item: ChecklistItem] remove: [id: number] + restore: [id: number] preview: [item: ChecklistItem] 'drag-start': [itemId: number] 'reorder-over': [itemId: number, event: MouseEvent] @@ -143,6 +152,8 @@ const strings = { editItem: t('pantry', 'Edit item'), moveItem: t('pantry', 'Move to list'), removeItem: t('pantry', 'Remove item'), + deletePermanently: t('pantry', 'Delete permanently'), + restoreItem: t('pantry', 'Restore'), } diff --git a/src/composables/useChecklist.ts b/src/composables/useChecklist.ts index 35c0e57..191957a 100644 --- a/src/composables/useChecklist.ts +++ b/src/composables/useChecklist.ts @@ -82,13 +82,16 @@ export function useChecklistItems(houseId: number, listId: number) { const loading = ref(false) const error = ref(null) const sortBy = ref('custom') + const trashMode = ref(false) async function load(sort?: ChecklistItemSort): Promise { loading.value = true error.value = null const s = sort ?? sortBy.value try { - items.value = await api.listItems(houseId, listId, s) + items.value = trashMode.value + ? await api.listDeletedItems(houseId, listId) + : await api.listItems(houseId, listId, s) } catch (e) { error.value = (e as Error).message } finally { @@ -150,6 +153,25 @@ export function useChecklistItems(houseId: number, listId: number) { items.value = items.value.filter((i) => i.id !== itemId) } + async function removePermanently(itemId: number): Promise { + await api.permanentlyDeleteItem(houseId, listId, itemId) + items.value = items.value.filter((i) => i.id !== itemId) + } + + async function restore(itemId: number): Promise { + await api.restoreItem(houseId, listId, itemId) + // The item leaves the current view: in trash mode it returns to the active + // list (and stays hidden here); in active mode it was never visible. + items.value = items.value.filter((i) => i.id !== itemId) + } + + async function emptyTrash(): Promise { + await api.emptyTrash(houseId, listId) + if (trashMode.value) { + items.value = [] + } + } + async function uploadImage(itemId: number, file: File): Promise { const updated = await api.uploadItemImage(houseId, listId, itemId, file) items.value = items.value.map((i) => (i.id === itemId ? updated : i)) @@ -165,12 +187,16 @@ export function useChecklistItems(houseId: number, listId: number) { loading, error, sortBy, + trashMode, load, add, update, toggle, reorderItems, remove, + removePermanently, + restore, + emptyTrash, uploadImage, clearImage, } diff --git a/src/views/ChecklistDetail.vue b/src/views/ChecklistDetail.vue index 3f5f052..fbfcd5e 100644 --- a/src/views/ChecklistDetail.vue +++ b/src/views/ChecklistDetail.vue @@ -13,7 +13,7 @@