feat(categories): per-category download permissions (#640 part B)

Adds an `allow_downloads` boolean to `photo_categories` so admins can
have different download policies per category — e.g. preview categories
public, originals client-only. AND's with the event-level `allow_downloads`,
so disabling at either level blocks downloads for that category's photos.

Defaults to true so categories created before migration 135 keep working
without admin intervention.

Credit: 8digit/picpeak@928164b + @751ec75.

### Backend

- **Migration 135**: additive `allow_downloads BOOLEAN NOT NULL DEFAULT true`
  on `photo_categories`, hasColumn-guarded + sane down.
- **`adminCategories.js`**: PUT /:id accepts optional `allow_downloads` patch.
- **`gallery.js`**:
  - `GET /:slug/photos` returns `allow_downloads` per category AND
    `category_allow_downloads` per photo.
  - `GET /:slug/download/:photoId` returns 403 when the photo's category
    disables downloads.
  - `GET /:slug/download-all` LEFT JOINs `photo_categories` and filters
    `whereNull(category_id) OR allow_downloads=true OR allow_downloads IS NULL`.
    The null check covers pre-migration-135 rows during the upgrade window.
  - `POST /:slug/download-selected` same filter pattern.

### Frontend

- **`categories.service.ts`**: `updateCategory()` gains an optional `patch`
  argument carrying `{ allow_downloads }`. PhotoCategory interface gains the
  optional field.
- **`EventCategoryManager.tsx`**: new toggle button next to the delete X.
  Green DownloadCloud icon when downloads are on, plain Download icon when
  off. Click toggles via the new mutation; toast confirms.
- **`PhotoLightbox.tsx`**: `photoAllowsDownload = allowDownloads && currentPhoto?.category_allow_downloads !== false`. Hides the download button +
  blocks the 'D' keyboard shortcut + early-returns from handleDownload.
- **Types**: Photo interface gains `category_allow_downloads`.
- **i18n**: 5 new EN + DE entries for the toggle button toast + tooltip.

No global-category surface change yet — global categories don't currently
have a UI for the toggle. Admins can still flip the column directly via SQL
or via a future global-categories editor.

### Test plan

- [x] Backend syntax + TS check clean
- [x] ESLint: no new warnings
- [ ] Manual: admin → event detail → categories panel → click DownloadCloud
      icon → category flips, toast confirms
- [ ] Manual: gallery (guest) → photo in disabled category → lightbox shows
      no download button, 'D' shortcut is a no-op
- [ ] Manual: download-all on a gallery with one disabled category →
      ZIP excludes that category's photos
- [ ] Manual: download-selected including a disabled-category photo → 404
      (filtered out) and the response carries only the allowed selection
- [ ] Manual: pre-migration-135 category (legacy row with NULL allow_downloads)
      → downloads still work (defaults true via fallback)
This commit is contained in:
Paul Nothaft
2026-06-18 22:29:35 +02:00
parent e4e79a0b3a
commit 820f4835f1
9 changed files with 181 additions and 26 deletions
+7 -1
View File
@@ -112,7 +112,8 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
}).withMessage('hero_photo_id must be an integer or null')
}).withMessage('hero_photo_id must be an integer or null'),
body('allow_downloads').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -144,6 +145,11 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
updateData.hero_photo_id = hero_photo_id || null;
}
// Per-category download permission (#640). AND with event-level allow_downloads.
if (Object.prototype.hasOwnProperty.call(req.body, 'allow_downloads')) {
updateData.allow_downloads = req.body.allow_downloads;
}
await db('photo_categories')
.where('id', id)
.update(updateData);