From 6901e2661ed74e69f19c52ce046ee911b818d463 Mon Sep 17 00:00:00 2001 From: Marian Date: Fri, 15 May 2026 19:40:03 +0000 Subject: [PATCH] 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) --- backend/src/routes/v1/events.js | 39 +++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js index 06ad6fcb..2de46fd6 100644 --- a/backend/src/routes/v1/events.js +++ b/backend/src/routes/v1/events.js @@ -338,6 +338,13 @@ router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res * required: [photo] * properties: * 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: * 201: * description: Photo uploaded @@ -351,6 +358,7 @@ router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res * path: { type: string } * thumbnail_path: { type: string, nullable: true } * size_bytes: { type: integer } + * category_id: { type: integer, nullable: true } * 400: { description: No file or invalid type } * 404: { description: Event not found } */ @@ -368,6 +376,25 @@ router.post( const event = await db('events').where({ id: req.params.id }).first(); 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 finalName = `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`; // photo.path is stored relative to events/active so resolvePhotoStorageKey @@ -408,7 +435,8 @@ router.post( original_filename: req.file.originalname, path: relPath, thumbnail_path: thumbRel, - type: 'individual', + type: photoType, + category_id: categoryId, size_bytes: stat.size, width, height, @@ -432,7 +460,14 @@ router.post( }); } 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) { logger.error('v1 POST /events/:id/photos failed', { error: error.message }); if (tempPath) await fs.unlink(tempPath).catch(() => {});