/**
* Protected API route to bulk import selections with labels and label subtypes
*
* @route POST /api/selections
* @authentication Required
* @permission ADMIN or CURATOR required
* @body {Object} Request body containing:
* - datasetId: string - ID of the dataset
* - filterId: string - ID of the filter to use
* - selections: Array of selection objects with:
* - fileId: string - ID of the file
* - startTime: number - Start time in seconds
* - endTime: number - End time in seconds
* - species: Array of species objects with:
* - speciesId: string - ID of the species
* - callTypes?: Array of call type IDs (optional)
* @returns {Object} Response containing:
* - data: Object with counts of created records
* - message: Success message
* @error 400 - Invalid request body or missing required fields
* @error 403 - Insufficient permissions
* @error 404 - Referenced entities not found
* @error 500 - Database error
*/
selections.post("/", authenticate, async (c) => {
try {
const jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;
const userId = jwtPayload.sub;
// Parse request body
const body = await c.req.json().catch(() => null);
if (!body || typeof body !== 'object') {
return c.json({
error: "Invalid request body: Expected JSON object"
}, 400);
}
const { datasetId, filterId, selections: selectionsData } = body as SelectionImportRequest;
// Validate required fields
if (!datasetId || typeof datasetId !== 'string') {
return c.json({
error: "Missing or invalid required field: datasetId"
}, 400);
}
if (!filterId || typeof filterId !== 'string') {
return c.json({
error: "Missing or invalid required field: filterId"
}, 400);
}
if (!selectionsData || !Array.isArray(selectionsData) || selectionsData.length === 0) {
return c.json({
error: "Missing or invalid required field: selections (must be non-empty array)"
}, 400);
}
// Validate selections structure
for (let i = 0; i < selectionsData.length; i++) {
const sel = selectionsData[i];
if (!sel.fileId || typeof sel.fileId !== 'string') {
return c.json({
error: `Invalid selection at index ${i}: missing or invalid fileId`
}, 400);
}
if (typeof sel.startTime !== 'number' || sel.startTime < 0) {
return c.json({
error: `Invalid selection at index ${i}: startTime must be a non-negative number`
}, 400);
}
if (typeof sel.endTime !== 'number' || sel.endTime <= sel.startTime) {
return c.json({
error: `Invalid selection at index ${i}: endTime must be greater than startTime`
}, 400);
}
if (!sel.species || !Array.isArray(sel.species) || sel.species.length === 0) {
return c.json({
error: `Invalid selection at index ${i}: species must be non-empty array`
}, 400);
}
for (let j = 0; j < sel.species.length; j++) {
const speciesItem = sel.species[j];
if (!speciesItem.speciesId || typeof speciesItem.speciesId !== 'string') {
return c.json({
error: `Invalid selection at index ${i}, species at index ${j}: missing or invalid speciesId`
}, 400);
}
if (speciesItem.callTypes && (!Array.isArray(speciesItem.callTypes) ||
speciesItem.callTypes.some((ct: unknown) => typeof ct !== 'string'))) {
return c.json({
error: `Invalid selection at index ${i}, species at index ${j}: callTypes must be array of strings`
}, 400);
}
}
}
// Connect to database
const db = createDatabase(c.env);
// Check if user has ADMIN or CURATOR permission for this dataset
const hasAdminPermission = await checkUserPermission(db, userId, datasetId, 'ADMIN');
const hasCuratorPermission = await checkUserPermission(db, userId, datasetId, 'CURATOR');
if (!hasAdminPermission && !hasCuratorPermission) {
return c.json({
error: "Access denied: ADMIN or CURATOR permission required for this dataset"
}, 403);
}
// Start transaction
const result = await db.transaction(async (tx) => {
// Verify all referenced entities exist
const allFileIds = [...new Set(selectionsData.map(s => s.fileId))];
const allSpeciesIds = [...new Set(selectionsData.flatMap((s: SelectionImportItem) => s.species.map((sp: SelectionImportSpecies) => sp.speciesId)))];
const allCallTypeIds = [...new Set(selectionsData.flatMap((s: SelectionImportItem) =>
s.species.flatMap((sp: SelectionImportSpecies) => sp.callTypes || [])
))];
// Check if filter exists
const filterExists = await tx.select({ id: filter.id })
.from(filter)
.where(eq(filter.id, filterId))
.limit(1);
if (filterExists.length === 0) {
throw new Error(`Filter with ID ${filterId} not found`);
}
// Check if all files exist and belong to the dataset
const fileResults = await tx.select({
id: file.id,
clusterId: file.clusterId
})
.from(file)
.innerJoin(cluster, eq(file.clusterId, cluster.id))
.innerJoin(location, eq(cluster.locationId, location.id))
.where(
and(
inArray(file.id, allFileIds),
eq(location.datasetId, datasetId),
eq(file.active, true)
)
);
if (fileResults.length !== allFileIds.length) {
const foundFileIds = fileResults.map(f => f.id);
const missingFileIds = allFileIds.filter(id => !foundFileIds.includes(id));
throw new Error(`Files not found in dataset: ${missingFileIds.join(', ')}`);
}
// Check if all species exist
const speciesResults = await tx.select({ id: species.id })
.from(species)
.where(inArray(species.id, allSpeciesIds));
if (speciesResults.length !== allSpeciesIds.length) {
const foundSpeciesIds = speciesResults.map(s => s.id);
const missingSpeciesIds = allSpeciesIds.filter(id => !foundSpeciesIds.includes(id));
throw new Error(`Species not found: ${missingSpeciesIds.join(', ')}`);
}
// Check if all call types exist (if any specified)
if (allCallTypeIds.length > 0) {
const callTypeResults = await tx.select({ id: callType.id })
.from(callType)
.where(inArray(callType.id, allCallTypeIds));
if (callTypeResults.length !== allCallTypeIds.length) {
const foundCallTypeIds = callTypeResults.map(ct => ct.id);
const missingCallTypeIds = allCallTypeIds.filter(id => !foundCallTypeIds.includes(id));
throw new Error(`Call types not found: ${missingCallTypeIds.join(', ')}`);
}
}
let createdSelectionsCount = 0;
let createdLabelsCount = 0;
let createdLabelSubtypesCount = 0;
// Process each selection
for (const selectionData of selectionsData) {
// Create selection record
const selectionId = nanoid();
await tx.insert(selection).values({
id: selectionId,
fileId: selectionData.fileId,
datasetId: datasetId,
startTime: selectionData.startTime.toString(),
endTime: selectionData.endTime.toString(),
freqLow: null,
freqHigh: null,
description: null,
upload: false,
approved: false,
isSolarNight: null,
isCivilNight: null,
moonPhase: null,
createdBy: userId,
createdAt: new Date(),
lastModified: new Date(),
modifiedBy: userId,
active: true
});
createdSelectionsCount++;
// Process each species for this selection
for (const speciesData of selectionData.species) {
// Create label record
const labelId = nanoid();
await tx.insert(label).values({
id: labelId,
selectionId: selectionId,
speciesId: speciesData.speciesId,
filterId: filterId,
createdBy: userId,
createdAt: new Date(),
lastModified: new Date(),
modifiedBy: userId,
active: true
});
createdLabelsCount++;
// Process call types if specified
if (speciesData.callTypes && speciesData.callTypes.length > 0) {
for (const callTypeId of speciesData.callTypes) {
await tx.insert(labelSubtype).values({
id: nanoid(),
labelId: labelId,
calltypeId: callTypeId,
createdBy: userId,
createdAt: new Date(),
lastModified: new Date(),
modifiedBy: userId,
active: true
});
createdLabelSubtypesCount++;
}
}
}
}
return {
selectionsCreated: createdSelectionsCount,
labelsCreated: createdLabelsCount,
labelSubtypesCreated: createdLabelSubtypesCount
};
});
// Return success response
return c.json({
data: {
selectionsCreated: result.selectionsCreated,
labelsCreated: result.labelsCreated,
labelSubtypesCreated: result.labelSubtypesCreated
},
message: "Selections imported successfully"
});
} catch (error) {
console.error("Error creating selections:", error);
return c.json(
{
error: "Failed to create selections",
details: error instanceof Error ? error.message : String(error),
},
500
);
}
});