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.
This commit is contained in:
iu
2026-10-11 20:54:54 +03:00
parent d5d3f55db8
commit 3b745fa737
16 changed files with 643 additions and 13 deletions
@@ -0,0 +1,94 @@
# 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.