}
});
/**
* Protected API route to update selection metadata
*
* @route PATCH /api/files/selections/:selectionId/metadata
* @authentication Required
* @param {string} selectionId - Selection ID from URL parameter
* @param {Object} metadata - Request body containing metadata object
* @returns {Object} Response containing updated metadata
* @error 400 - If selectionId is invalid or metadata is missing
* @error 403 - If user doesn't have EDIT permission for the dataset
* @error 404 - If selection not found
* @error 500 - If database operation fails
* @description Updates the metadata for a specific selection (distance, note, etc.)
*
* Request body:
* {
* "metadata": {
* "distance": "close",
* "note": "Baby Kiwi maybe?"
* }
* }
*/
files.patch("/selections/:selectionId/metadata", authenticate, async (c) => {
try {
const jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;
const userId = jwtPayload.sub;
const selectionId = c.req.param("selectionId");
// Validate selection ID format
if (!isValidFileId(selectionId)) {
return c.json({
error: "Invalid selection ID format"
}, 400);
}
// Parse request body
const body = await c.req.json();
const { metadata } = body;
if (!metadata || typeof metadata !== 'object') {
return c.json({
error: "Missing or invalid metadata object"
}, 400);
}
// Connect to database
const db = createDatabase(c.env);
// Get selection and dataset info for permission check
const selectionResult = await db
.select({
id: selection.id,
datasetId: selection.datasetId,
active: selection.active
})
.from(selection)
.where(eq(selection.id, selectionId))
.limit(1);
if (selectionResult.length === 0) {
return c.json({
error: "Selection not found"
}, 404);
}
const selectionRecord = selectionResult[0];
if (!selectionRecord.active) {
return c.json({
error: "Selection is not active"
}, 404);
}
// Check if user has EDIT permission for this dataset
const hasPermission = await checkUserPermission(db, userId, selectionRecord.datasetId, 'EDIT');
if (!hasPermission) {
return c.json({
error: "Access denied: EDIT permission required for this dataset"
}, 403);
}
// Check if selection metadata already exists
const existingMetadata = await db
.select({
selectionId: selectionMetadata.selectionId
})
.from(selectionMetadata)
.where(eq(selectionMetadata.selectionId, selectionId))
.limit(1);
const now = new Date();
if (existingMetadata.length > 0) {
// Update existing metadata
await db
.update(selectionMetadata)
.set({
json: metadata,
lastModified: now,
modifiedBy: userId
})
.where(eq(selectionMetadata.selectionId, selectionId));
} else {
// Insert new metadata
await db
.insert(selectionMetadata)
.values({
selectionId,
json: metadata,
createdAt: now,
createdBy: userId,
lastModified: now,
modifiedBy: userId
});
}
return c.json({
data: {
selectionId,
metadata
}
});
} catch (error) {
return c.json(standardErrorResponse(error, "updating selection metadata"), 500);
}
});
/**
* Protected API route to update selection species and call types
*
* @route PATCH /api/files/selections/:selectionId/labels
* @authentication Required
* @param {string} selectionId - Selection ID from URL parameter
* @param {Object} labels - Request body containing label updates
* @returns {Object} Response containing updated labels
* @error 400 - If selectionId is invalid or labels data missing
* @error 403 - If user doesn't have EDIT permission for the dataset
* @error 404 - If selection not found
* @error 500 - If database operation fails
* @description Updates the species and call types for a specific selection
*
* Request body:
* {
* "labels": [
* {
* "speciesId": "species123",
* "certainty": 95.5,
* "callTypes": [
* {
* "callTypeId": "calltype456",
* "certainty": 90.0
* }
* ]
* }
* ],
* "filterId": "filter123"
* }
*/
files.patch("/selections/:selectionId/labels", authenticate, async (c) => {
try {
const jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;
const userId = jwtPayload.sub;
const selectionId = c.req.param("selectionId");
// Validate selection ID format
if (!isValidFileId(selectionId)) {
return c.json({
error: "Invalid selection ID format"
}, 400);
}
// Parse request body
const body = await c.req.json();
const { labels, filterId } = body;
if (!Array.isArray(labels)) {
return c.json({
error: "Missing or invalid labels array"
}, 400);
}
// Connect to database
const db = createDatabase(c.env);
// Get selection and dataset info for permission check
const selectionResult = await db
.select({
id: selection.id,
datasetId: selection.datasetId,
active: selection.active
})
.from(selection)
.where(eq(selection.id, selectionId))
.limit(1);
if (selectionResult.length === 0) {
return c.json({
error: "Selection not found"
}, 404);
}
const selectionRecord = selectionResult[0];
if (!selectionRecord.active) {
return c.json({
error: "Selection is not active"
}, 404);
}
// Check if user has EDIT permission for this dataset
const hasPermission = await checkUserPermission(db, userId, selectionRecord.datasetId, 'EDIT');
if (!hasPermission) {
return c.json({
error: "Access denied: EDIT permission required for this dataset"
}, 403);
}
// Start transaction for label updates
const now = new Date();
// First, deactivate existing labels and subtypes for this selection
await db
.update(label)
.set({
active: false,
lastModified: now,
modifiedBy: userId
})
.where(eq(label.selectionId, selectionId));
// Deactivate existing label subtypes by getting all label IDs first
const existingLabels = await db
.select({ id: label.id })
.from(label)
.where(eq(label.selectionId, selectionId));
if (existingLabels.length > 0) {
const labelIds = existingLabels.map(l => l.id);
await db
.update(labelSubtype)
.set({
active: false,
lastModified: now,
modifiedBy: userId
})
.where(inArray(labelSubtype.labelId, labelIds));
}
// Insert new labels and subtypes
const newLabels = [];
const newSubtypes = [];
for (const labelData of labels) {
if (!labelData.speciesId) continue;
const labelId = nanoid();
newLabels.push({
id: labelId,
selectionId,
speciesId: labelData.speciesId,
filterId: filterId || null,
certainty: labelData.certainty || null,
createdAt: now,
createdBy: userId,
lastModified: now,
modifiedBy: userId,
active: true
});
// Add call types if present
if (labelData.callTypes && Array.isArray(labelData.callTypes)) {
for (const callTypeData of labelData.callTypes) {
if (!callTypeData.callTypeId) continue;
const subtypeId = nanoid();
newSubtypes.push({
id: subtypeId,
labelId,
calltypeId: callTypeData.callTypeId,
filterId: filterId || null,
certainty: callTypeData.certainty || null,
createdAt: now,
createdBy: userId,
lastModified: now,
modifiedBy: userId,
active: true
});
}
}
}
// Insert new labels
if (newLabels.length > 0) {
await db.insert(label).values(newLabels);
}
// Insert new subtypes
if (newSubtypes.length > 0) {
await db.insert(labelSubtype).values(newSubtypes);
}
return c.json({
data: {
selectionId,
labelsCreated: newLabels.length,
subtypesCreated: newSubtypes.length
}
});
} catch (error) {
return c.json(standardErrorResponse(error, "updating selection labels"), 500);