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:
co-authored by
Claude Opus 4.7
parent
2619049a95
commit
6901e2661e
@@ -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(() => {});
|
||||||
|
|||||||
Reference in New Issue
Block a user