feat(admin): multi-select episode file deletion — checkboxes on downloaded rows

The plan's per-EPISODE bulk select for the admin view (§0 item 8
follow-up — trimmed in the first admin-view commit; user called it
back). Admins now tick checkboxes on DOWNLOADED episode rows and delete
their files in one request:

- Checkbox per row, rendered ONLY for admins + downloaded episodes
  (nothing to delete otherwise), via the existing podcastDetail Alpine
  component: selection ids/count survive the list's htmx swaps
  (reapplied on afterSwap — same pattern as the text filter) and clear
  after a successful bulk delete.
- Toolbar segment outside #episode-list (survives swaps): 'N selected'
  + 'Delete selected files' (hx-include gathers the CHECKED boxes);
  disabled-looking at zero selection; hx-confirm states the
  re-materialization contract. CSP-safe method bindings only
  (bulkSelectionLabel/Class) — no ternaries in Alpine expressions.
- POST /feeds/:id/episodes/delete-files (admin): repeated
  episode-file-select keys parsed via ParseForm directly (never bound —
  repeated-key wire is another echo-bind gotcha); each id validated to
  belong to THIS feed, so a forged foreign id is refused while the real
  selections apply; rows reset to pending, files removed.
- Tests: bulk of two real ids + one forged id → deleted:2, files gone,
  rows pending; non-admin 403; all prior single/feed-level pins hold.
This commit is contained in:
John O'Keefe committed 2026-10-30 14:06:54 -04:00
1 parent 0741e98ea5
commit 7d5bbd1177
6 files changed
+543 -272

No files matched your search

+52
View File
@@ -120,6 +120,58 @@ func (h *PodcastHandler) AdminDeleteEpisodeFile(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "file deleted"})
}
// AdminDeleteEpisodeFilesBulk POST /api/podcasts/feeds/:id/episodes/delete-files —
// admin-only (§0 item 8): multi-select file deletion. Body carries the
// CHECKED boxes' values (form-encoded, repeated episode-file-select
// keys — hx-include gathers them; parsed directly, never bound: the
// repeated-key wire is another echo-bind gotcha). Each id is validated
// to belong to THIS feed; rows reset to pending, files removed.
func (h *PodcastHandler) AdminDeleteEpisodeFilesBulk(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
}
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
if err := c.Request().ParseForm(); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
rawIDs := c.Request().PostForm["episode-file-select"]
if len(rawIDs) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no episodes selected"})
}
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
deleted := 0
for _, raw := range rawIDs {
episodeID, perr := parseUUID(raw)
if perr != nil {
continue
}
// The checkbox list only renders this feed's rows, but the wire
// is user-controlled: a forged id for a foreign feed's episode
// is refused (feed-match guard), not silently applied.
ep, gerr := h.db.GetPodcastEpisode(actx, episodeID)
if gerr != nil || ep.SourceFeedID != feedID {
continue
}
if derr := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); derr == nil {
deleted++
}
}
log.Printf("[PODCAST] admin: bulk-deleted files for %d/%d episode(s) of feed %s (user %s)",
deleted, len(rawIDs), feedID, user.Username)
if isHXRequest(c) {
return h.hxEpisodeList(c, feedID.String(), false, false)
}
return c.JSON(http.StatusOK, map[string]interface{}{"deleted": deleted})
}
// flipSubscriptionToAll implements issue #3: Download All (button or
// add-feed checkbox) sets the CALLER's subscription policy to 'all' —
// per-user, never global (files are shared D6, policies are not). The