feat(api/v1): accept category_id on POST /events/:id/photos

The v1 photo upload endpoint previously ignored any caller-supplied
category and inserted photos with category_id=NULL. That meant
programmatic uploads via API tokens (e.g. a photobox sidecar) landed
in picpeak as uncategorized, forcing operators to bulk-assign category
in the admin UI after each event.

Mirror the adminPhotos.js category-handling logic on v1:
- Read optional `category_id` from the multipart form body.
- Reject unknown ids with 400 (with the id in the error) so callers
  fail fast on misconfigured envs instead of silently uncategorized
  uploads.
- Set photos.category_id on insert.
- Flip photos.type to 'collage' when the category's slug is
  collage/collages, matching adminPhotos.

Backwards-compatible: omitting category_id keeps the prior behavior
(insert with NULL category, type='individual'). OpenAPI spec + 201
response body updated to include the new field.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Marian
2026-05-18 07:21:43 +00:00
co-authored by Claude Opus 4.7
parent 2619049a95
commit 6901e2661e
+37 -2
View File
@@ -338,6 +338,13 @@ router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res
* required: [photo] * required: [photo]
* properties: * properties:
* photo: { type: string, format: binary } * photo: { type: string, format: binary }
* category_id:
* type: integer
* description: |
* Optional. If provided, the photo is filed under the
* given photo_categories.id (must belong to the event
* or be a global category). If omitted, the photo
* lands uncategorized.
* responses: * responses:
* 201: * 201:
* description: Photo uploaded * description: Photo uploaded
@@ -351,6 +358,7 @@ router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res
* path: { type: string } * path: { type: string }
* thumbnail_path: { type: string, nullable: true } * thumbnail_path: { type: string, nullable: true }
* size_bytes: { type: integer } * size_bytes: { type: integer }
* category_id: { type: integer, nullable: true }
* 400: { description: No file or invalid type } * 400: { description: No file or invalid type }
* 404: { description: Event not found } * 404: { description: Event not found }
*/ */
@@ -368,6 +376,25 @@ router.post(
const event = await db('events').where({ id: req.params.id }).first(); const event = await db('events').where({ id: req.params.id }).first();
if (!event) return res.status(404).json({ error: 'Event not found' }); if (!event) return res.status(404).json({ error: 'Event not found' });
// Optional category assignment, mirroring the admin upload route
// (adminPhotos.js). Multipart form field `category_id`. If the
// category looks up to a "collage" slug, the photo's `type` flips
// accordingly so existing collage-aware UI paths still work.
const rawCategoryId = req.body?.category_id;
const parsedCategoryId = rawCategoryId ? parseInt(rawCategoryId, 10) : NaN;
let categoryId = null;
let photoType = 'individual';
if (!Number.isNaN(parsedCategoryId)) {
const category = await db('photo_categories').where({ id: parsedCategoryId }).first();
if (!category) {
return res.status(400).json({ error: `Unknown category_id ${parsedCategoryId}` });
}
categoryId = category.id;
if (category.slug === 'collage' || category.slug === 'collages') {
photoType = 'collage';
}
}
const ext = path.extname(req.file.originalname); const ext = path.extname(req.file.originalname);
const finalName = `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`; const finalName = `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`;
// photo.path is stored relative to events/active so resolvePhotoStorageKey // photo.path is stored relative to events/active so resolvePhotoStorageKey
@@ -408,7 +435,8 @@ router.post(
original_filename: req.file.originalname, original_filename: req.file.originalname,
path: relPath, path: relPath,
thumbnail_path: thumbRel, thumbnail_path: thumbRel,
type: 'individual', type: photoType,
category_id: categoryId,
size_bytes: stat.size, size_bytes: stat.size,
width, width,
height, height,
@@ -432,7 +460,14 @@ router.post(
}); });
} catch (e) { /* non-fatal */ } } catch (e) { /* non-fatal */ }
res.status(201).json({ id, filename: finalName, path: relPath, thumbnail_path: thumbRel, size_bytes: stat.size }); res.status(201).json({
id,
filename: finalName,
path: relPath,
thumbnail_path: thumbRel,
size_bytes: stat.size,
category_id: categoryId
});
} catch (error) { } catch (error) {
logger.error('v1 POST /events/:id/photos failed', { error: error.message }); logger.error('v1 POST /events/:id/photos failed', { error: error.message });
if (tempPath) await fs.unlink(tempPath).catch(() => {}); if (tempPath) await fs.unlink(tempPath).catch(() => {});