Files
ds-galo/openspec/changes/trash-permanent-delete-and-empty/design.md
T
iu 3b745fa737 feat(photos): fix trash permanent delete and add empty trash
Permanently deleting a photo whose album was deleted failed because
authorization resolved through the album. Fall back to uploader
ownership when the photo is albumless. Add a bulk DELETE /trash that
removes every trashed photo the caller may delete (uncapped listing)
and returns the deleted count, with an «Очистить корзину» action on the
trash screen.
2026-10-11 20:54:54 +03:00

95 lines
4.8 KiB
Markdown

# Design
## Context
See `proposal.md` - Why. Relevant current state:
- `photos.Service.PermanentDelete` (backend/internal/photos/service.go:592) resolves
authorization via `albums.Access(albumID, userID)` before `HardDelete`. A photo
whose album was deleted has `album_id = NULL`; `Access("")` returns
`album_not_found`, so the delete can never proceed. `Restore` already special-cases
the missing album (`album_required`), but `PermanentDelete` does not.
- The trash listing (`photos.Store.ListTrash`) is capped at `LIMIT 200` for display,
while `TrashCount` is uncapped. A bulk purge must not silently stop at 200.
- Object cleanup after `HardDelete` is per-photo: `PhotoObjectIDs` then
`MarkObjectDeleting` for orphans, with a background worker removing blobs.
- The trash screen (frontend/src/routes/(app)/trash/+page.svelte) has per-photo
actions only, and `ConfirmDialog` is already available for confirmations.
## Goals / Non-Goals
**Goals:**
- Permanently deleting an albumless trashed photo works for its uploader.
- One action empties the trash for everything the caller may delete, regardless of the
200-item display cap, and reports the number removed.
- Consistent object garbage collection for every removed photo.
**Non-Goals:**
- Trash retention/auto-expiry policies.
- Changing trash visibility rules (`ListTrash`) or the 200-item display cap.
- Set-based SQL bulk deletion in this change.
## Decisions
### Authorize albumless permanent deletion by uploader
When `p.AlbumID == ""`, `PermanentDelete` SHALL fall back to `p.UploaderID == userID`
for authorization instead of calling `albums.Access`. When an album is present, the
existing album-role check is unchanged. This mirrors `Restore`, which already treats a
missing album as a distinct path, and keeps the permission model identical: only a
photo's uploader can remove a photo that no longer belongs to an album.
- Alternative considered: require the client to always supply a target album for
delete. Rejected - permanent deletion has no target album, and the trash legitimately
holds albumless photos.
### Empty trash as a server-side operation on the trash resource
Add `DELETE /trash` returning `{ "deleted": <n> }`, backed by
`photos.Service.EmptyTrash(ctx, userID)`. It iterates the caller's accessible trashed
photos and calls the existing `PermanentDelete` for each, counting successes and
skipping photos the caller may not delete. Reusing `PermanentDelete` keeps authorization
and object GC identical to single-photo deletion.
- Alternative considered: client enumerates trash and calls `POST /photo-batch/...`
with every id. Rejected - the display list is capped at 200 and this is racy; the
server already knows the full accessible set.
- Alternative considered: a single `DELETE FROM photos ... WHERE <authorization>`. Rejected
for now - album-role authorization lives in Go (`roles.CanDelete*`), and per-photo object
GC is required anyway.
### Bypass the 200-item display cap for purging
`EmptyTrash` must see more than the display page. Add a store method that returns all
accessible trashed photo identities (id, album_id, uploader_id) with no display cap, and
authorize each in the service. This avoids looping on a capped list (which would also
risk an infinite loop when some items are not deletable).
- Alternative considered: paginate `ListTrash` until a pass removes nothing. Rejected -
undeletable items would reappear and a zero-progress pass is needed to stop; the
uncapped identity listing is simpler and bounded by the user's own trash size.
### Frontend: explicit action plus confirmation
Add an «Очистить корзину» control on the trash screen header, enabled when the list is
non-empty. It opens the existing `ConfirmDialog`; on confirm it calls `DELETE /trash`,
then reloads the list and refreshes `trash` store count. Show a busy state while running.
## Risks / Trade-offs
- [Large trash makes the per-photo loop slow] -> Each `PermanentDelete` is a small fixed
number of queries; bulk deletes are O(n) round trips. Acceptable for now; a set-based
purge can replace the loop later without changing the spec behavior. Keep the frontend
busy state to make latency legible.
- [Uncapped listing memory] -> The query returns only the caller's trashed photo
identities; bounded in practice by trash size. Revisit with chunking if it grows.
- [Partial failure mid-purge] -> `EmptyTrash` skips failures and returns the count
deleted; the remaining items stay in the trash and can be retried.
- [Albumless deletes and realtime] -> `PermanentDelete` only publishes when `AlbumID != ""`;
albumless removals publish nothing, which is correct since no album is watching.
## Migration Plan
No schema change. Deploy backend and frontend together; the new endpoint is additive and
the authorizaton fix is behavior-only. Rollback is reverting the deploy.
## Open Questions
None blocking.